mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-03-20 11:07:31 +08:00
feat: optimize dropdown for mobile
This commit is contained in:
@@ -32,7 +32,7 @@
|
|||||||
<i class="fas fa-caret-down dropdown-caret"></i>
|
<i class="fas fa-caret-down dropdown-caret"></i>
|
||||||
</slot>
|
</slot>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="open && (loading || filteredOptions.length > 0 || showSearch)" :class="['dropdown-menu', menuClass]">
|
<div v-if="open && !isMobile && (loading || filteredOptions.length > 0 || showSearch)" :class="['dropdown-menu', menuClass]">
|
||||||
<div v-if="showSearch" class="dropdown-search">
|
<div v-if="showSearch" class="dropdown-search">
|
||||||
<i class="fas fa-search search-icon"></i>
|
<i class="fas fa-search search-icon"></i>
|
||||||
<input type="text" v-model="search" placeholder="搜索" />
|
<input type="text" v-model="search" placeholder="搜索" />
|
||||||
@@ -53,12 +53,40 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="open && isMobile" class="dropdown-mobile-page">
|
||||||
|
<div class="dropdown-mobile-header">
|
||||||
|
<i class="fas fa-arrow-left" @click="close"></i>
|
||||||
|
<span class="mobile-title">{{ placeholder }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="dropdown-mobile-menu">
|
||||||
|
<div v-if="showSearch" class="dropdown-search">
|
||||||
|
<i class="fas fa-search search-icon"></i>
|
||||||
|
<input type="text" v-model="search" placeholder="搜索" />
|
||||||
|
</div>
|
||||||
|
<div v-if="loading" class="dropdown-loading">
|
||||||
|
<l-hatch size="20" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||||
|
</div>
|
||||||
|
<template v-else>
|
||||||
|
<div v-for="o in filteredOptions" :key="o.id" @click="select(o.id)"
|
||||||
|
:class="['dropdown-option', optionClass, { 'selected': isSelected(o.id) }]">
|
||||||
|
<slot name="option" :option="o" :isSelected="isSelected(o.id)">
|
||||||
|
<template v-if="o.icon">
|
||||||
|
<img v-if="isImageIcon(o.icon)" :src="o.icon" class="option-icon" />
|
||||||
|
<i v-else :class="['option-icon', o.icon]"></i>
|
||||||
|
</template>
|
||||||
|
<span>{{ o.name }}</span>
|
||||||
|
</slot>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||||
import { hatch } from 'ldrs'
|
import { hatch } from 'ldrs'
|
||||||
|
import { isMobile } from '../utils/screen'
|
||||||
hatch.register()
|
hatch.register()
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@@ -74,8 +102,8 @@ export default {
|
|||||||
showSearch: { type: Boolean, default: true },
|
showSearch: { type: Boolean, default: true },
|
||||||
initialOptions: { type: Array, default: () => [] }
|
initialOptions: { type: Array, default: () => [] }
|
||||||
},
|
},
|
||||||
emits: ['update:modelValue'],
|
emits: ['update:modelValue', 'update:search', 'close'],
|
||||||
setup(props, { emit }) {
|
setup(props, { emit, expose }) {
|
||||||
const open = ref(false)
|
const open = ref(false)
|
||||||
const search = ref('')
|
const search = ref('')
|
||||||
const setSearch = (val) => {
|
const setSearch = (val) => {
|
||||||
@@ -88,10 +116,12 @@ export default {
|
|||||||
|
|
||||||
const toggle = () => {
|
const toggle = () => {
|
||||||
open.value = !open.value
|
open.value = !open.value
|
||||||
|
if (!open.value) emit('close')
|
||||||
}
|
}
|
||||||
|
|
||||||
const close = () => {
|
const close = () => {
|
||||||
open.value = false
|
open.value = false
|
||||||
|
emit('close')
|
||||||
}
|
}
|
||||||
|
|
||||||
const select = id => {
|
const select = id => {
|
||||||
@@ -157,6 +187,7 @@ export default {
|
|||||||
})
|
})
|
||||||
|
|
||||||
watch(search, async val => {
|
watch(search, async val => {
|
||||||
|
emit('update:search', val)
|
||||||
if (props.remote && open.value) {
|
if (props.remote && open.value) {
|
||||||
await loadOptions(val)
|
await loadOptions(val)
|
||||||
}
|
}
|
||||||
@@ -190,9 +221,12 @@ export default {
|
|||||||
return /^https?:\/\//.test(icon) || icon.startsWith('/')
|
return /^https?:\/\//.test(icon) || icon.startsWith('/')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
expose({ toggle, close })
|
||||||
|
|
||||||
return {
|
return {
|
||||||
open,
|
open,
|
||||||
toggle,
|
toggle,
|
||||||
|
close,
|
||||||
select,
|
select,
|
||||||
search,
|
search,
|
||||||
filteredOptions,
|
filteredOptions,
|
||||||
@@ -201,7 +235,8 @@ export default {
|
|||||||
isSelected,
|
isSelected,
|
||||||
loading,
|
loading,
|
||||||
isImageIcon,
|
isImageIcon,
|
||||||
setSearch
|
setSearch,
|
||||||
|
isMobile
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -292,4 +327,29 @@ export default {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 10px 0;
|
padding: 10px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dropdown-mobile-page {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: var(--menu-background-color);
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-mobile-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
border-bottom: 1px solid var(--normal-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-mobile-menu {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -13,6 +13,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="isLogin" class="header-content-right">
|
<div v-if="isLogin" class="header-content-right">
|
||||||
|
<div v-if="isMobile" class="search-icon" @click="showSearch = true">
|
||||||
|
<i class="fas fa-search"></i>
|
||||||
|
</div>
|
||||||
<DropdownMenu ref="userMenu" :items="headerMenuItems">
|
<DropdownMenu ref="userMenu" :items="headerMenuItems">
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
<div class="avatar-container">
|
<div class="avatar-container">
|
||||||
@@ -24,21 +27,27 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="header-content-right">
|
<div v-else class="header-content-right">
|
||||||
|
<div v-if="isMobile" class="search-icon" @click="showSearch = true">
|
||||||
|
<i class="fas fa-search"></i>
|
||||||
|
</div>
|
||||||
<div class="header-content-item-main" @click="goToLogin">登录</div>
|
<div class="header-content-item-main" @click="goToLogin">登录</div>
|
||||||
<div class="header-content-item-secondary" @click="goToSignup">注册</div>
|
<div class="header-content-item-secondary" @click="goToSignup">注册</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
<SearchDropdown v-if="isMobile && showSearch" @close="showSearch = false" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { authState, clearToken, loadCurrentUser } from '../utils/auth'
|
import { authState, clearToken, loadCurrentUser } from '../utils/auth'
|
||||||
import { watch } from 'vue'
|
import { watch } from 'vue'
|
||||||
import DropdownMenu from './DropdownMenu.vue'
|
import DropdownMenu from './DropdownMenu.vue'
|
||||||
|
import SearchDropdown from './SearchDropdown.vue'
|
||||||
|
import { isMobile } from '../utils/screen'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'HeaderComponent',
|
name: 'HeaderComponent',
|
||||||
components: { DropdownMenu },
|
components: { DropdownMenu, SearchDropdown },
|
||||||
props: {
|
props: {
|
||||||
showMenuBtn: {
|
showMenuBtn: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
@@ -47,13 +56,17 @@ export default {
|
|||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
avatar: ''
|
avatar: '',
|
||||||
|
showSearch: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
isLogin() {
|
isLogin() {
|
||||||
return authState.loggedIn
|
return authState.loggedIn
|
||||||
},
|
},
|
||||||
|
isMobile() {
|
||||||
|
return isMobile.value
|
||||||
|
},
|
||||||
headerMenuItems() {
|
headerMenuItems() {
|
||||||
return [
|
return [
|
||||||
{ text: '设置', onClick: this.goToSettings },
|
{ text: '设置', onClick: this.goToSettings },
|
||||||
@@ -80,6 +93,7 @@ export default {
|
|||||||
|
|
||||||
watch(() => this.$route.fullPath, () => {
|
watch(() => this.$route.fullPath, () => {
|
||||||
if (this.$refs.userMenu) this.$refs.userMenu.close()
|
if (this.$refs.userMenu) this.$refs.userMenu.close()
|
||||||
|
this.showSearch = false
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -227,6 +241,11 @@ export default {
|
|||||||
background-color: var(--menu-selected-background-color);
|
background-color: var(--menu-selected-background-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.search-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 1200px) {
|
@media (max-width: 1200px) {
|
||||||
.header-content {
|
.header-content {
|
||||||
padding-left: 15px;
|
padding-left: 15px;
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="search-dropdown">
|
<div class="search-dropdown">
|
||||||
<Dropdown v-model="selected" :fetch-options="fetchResults" remote menu-class="search-menu"
|
<Dropdown ref="dropdown" v-model="selected" :fetch-options="fetchResults" remote menu-class="search-menu"
|
||||||
option-class="search-option" :show-search="false">
|
option-class="search-option" :show-search="isMobile" @update:search="keyword = $event" @close="onClose">
|
||||||
<template #display="{ toggle, setSearch }">
|
<template #display="{ toggle, setSearch }">
|
||||||
<div class="search-input" @click="toggle">
|
<div v-if="isMobile" class="search-mobile-trigger" @click="toggle">
|
||||||
|
<i class="fas fa-search"></i>
|
||||||
|
</div>
|
||||||
|
<div v-else class="search-input" @click="toggle">
|
||||||
<i class="search-input-icon fas fa-search"></i>
|
<i class="search-input-icon fas fa-search"></i>
|
||||||
<input class="text-input" v-model="keyword" placeholder="Search" @focus="toggle" @input="setSearch(keyword)" />
|
<input class="text-input" v-model="keyword" placeholder="Search" @focus="toggle" @input="setSearch(keyword)" />
|
||||||
</div>
|
</div>
|
||||||
@@ -23,7 +26,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { ref, watch } from 'vue'
|
import { ref, watch, onMounted } from 'vue'
|
||||||
|
import { isMobile } from '../utils/screen'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import Dropdown from './Dropdown.vue'
|
import Dropdown from './Dropdown.vue'
|
||||||
import { API_BASE_URL } from '../main'
|
import { API_BASE_URL } from '../main'
|
||||||
@@ -32,11 +36,15 @@ import { stripMarkdown } from '../utils/markdown'
|
|||||||
export default {
|
export default {
|
||||||
name: 'SearchDropdown',
|
name: 'SearchDropdown',
|
||||||
components: { Dropdown },
|
components: { Dropdown },
|
||||||
setup() {
|
emits: ['close'],
|
||||||
|
setup(props, { emit }) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const keyword = ref('')
|
const keyword = ref('')
|
||||||
const selected = ref(null)
|
const selected = ref(null)
|
||||||
const results = ref([])
|
const results = ref([])
|
||||||
|
const dropdown = ref(null)
|
||||||
|
|
||||||
|
const onClose = () => emit('close')
|
||||||
|
|
||||||
const fetchResults = async (kw) => {
|
const fetchResults = async (kw) => {
|
||||||
if (!kw) return []
|
if (!kw) return []
|
||||||
@@ -68,6 +76,12 @@ export default {
|
|||||||
comment: 'fas fa-comment'
|
comment: 'fas fa-comment'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (isMobile.value && dropdown.value) {
|
||||||
|
dropdown.value.toggle()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
watch(selected, val => {
|
watch(selected, val => {
|
||||||
if (!val) return
|
if (!val) return
|
||||||
const opt = results.value.find(r => r.id === val)
|
const opt = results.value.find(r => r.id === val)
|
||||||
@@ -85,7 +99,7 @@ export default {
|
|||||||
keyword.value = ''
|
keyword.value = ''
|
||||||
})
|
})
|
||||||
|
|
||||||
return { keyword, selected, fetchResults, highlight, iconMap }
|
return { keyword, selected, fetchResults, highlight, iconMap, isMobile, dropdown, onClose }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -96,6 +110,11 @@ export default {
|
|||||||
width: 500px;
|
width: 500px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.search-mobile-trigger {
|
||||||
|
padding: 10px;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
.search-input {
|
.search-input {
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -118,6 +137,12 @@ export default {
|
|||||||
max-width: 600px;
|
max-width: 600px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.search-dropdown {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.search-option-item {
|
.search-option-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
|||||||
Reference in New Issue
Block a user