TreeSelect.vue 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722
  1. <!--
  2. Composant de sélection hiérarchique à plusieurs niveaux permettant de naviguer
  3. et sélectionner des éléments organisés en catégories et sous-catégories.
  4. ## Exemple d'utilisation
  5. ```vue
  6. <TreeSelect
  7. v-model="selectedValues"
  8. :items="hierarchicalItems"
  9. :max-visible-chips="3"
  10. label="Sélectionner des éléments"
  11. />
  12. ```
  13. -->
  14. <template>
  15. <v-select
  16. :model-value="modelValue"
  17. :label="$t(label)"
  18. v-bind="$attrs"
  19. :items="flattenedItems"
  20. item-title="label"
  21. item-value="value"
  22. :variant="variant"
  23. multiple
  24. :menu-props="{ maxHeight: 400 }"
  25. @update:menu="onMenuUpdate"
  26. @update:model-value="$emit('update:modelValue', $event)"
  27. >
  28. <template #prepend-item>
  29. <!-- Champs de recherche textuelle -->
  30. <v-text-field
  31. ref="searchInput"
  32. v-model="searchText"
  33. density="compact"
  34. hide-details
  35. :placeholder="$t('search') + '...'"
  36. prepend-inner-icon="fas fa-magnifying-glass"
  37. variant="outlined"
  38. clearable
  39. class="mx-2 my-2"
  40. @click.stop="focusSearchInput"
  41. @mousedown.stop
  42. @keydown.stop="onKeyDown"
  43. @input="onSearchInputDebounced"
  44. @click:clear.stop="onSearchClear"
  45. />
  46. <v-divider class="mt-2" />
  47. </template>
  48. <template #selection="{ item, index }">
  49. <v-chip
  50. v-if="maxVisibleChips && index < maxVisibleChips"
  51. :key="item.raw.value"
  52. closable
  53. @click:close="removeItem(String(item.raw.value!))"
  54. >
  55. {{ selectedItemsMap[item.raw.value] }}
  56. </v-chip>
  57. <span
  58. v-if="
  59. maxVisibleChips &&
  60. index === maxVisibleChips &&
  61. modelValue.length > maxVisibleChips
  62. "
  63. class="text-grey text-caption align-self-center"
  64. >
  65. (+{{ modelValue.length - maxVisibleChips }} {{ $t('others') }})
  66. </span>
  67. </template>
  68. <template #item="{ item }">
  69. <template v-if="item.raw.type === 'category'">
  70. <v-list-item
  71. :ripple="false"
  72. :class="{
  73. 'v-list-item--active': expandedCategories.has(item.raw.id),
  74. }"
  75. @click.stop="toggleCategory(item.raw.id)"
  76. >
  77. <template #prepend>
  78. <v-icon
  79. :icon="
  80. 'fas ' +
  81. (expandedCategories.has(item.raw.id)
  82. ? 'fa-angle-down'
  83. : 'fa-angle-right')
  84. "
  85. size="small"
  86. />
  87. </template>
  88. <v-list-item-title class="font-weight-medium">
  89. {{ item.raw.label }}
  90. </v-list-item-title>
  91. </v-list-item>
  92. </template>
  93. <template v-else-if="item.raw.type === 'subcategory'">
  94. <v-list-item
  95. :ripple="false"
  96. :class="{
  97. 'v-list-item--active': expandedSubcategories.has(item.raw.id),
  98. 'pl-8': true,
  99. }"
  100. @click.stop="toggleSubcategory(item.raw.id)"
  101. >
  102. <template #prepend>
  103. <v-icon
  104. :icon="
  105. 'fas ' +
  106. (expandedSubcategories.has(item.raw.id)
  107. ? 'fa-angle-down'
  108. : 'fa-angle-right')
  109. "
  110. size="small"
  111. />
  112. </template>
  113. <v-list-item-title>
  114. {{ item.raw.label }}
  115. </v-list-item-title>
  116. </v-list-item>
  117. </template>
  118. <template v-else>
  119. <v-list-item
  120. :active="modelValue.includes(String(item.raw.value!))"
  121. :class="{
  122. 'd-flex': true,
  123. 'pl-12': item.raw.level === 2,
  124. 'pl-8': item.raw.level === 1,
  125. }"
  126. @click="toggleItem(String(item.raw.value!))"
  127. >
  128. <template #prepend>
  129. <v-checkbox
  130. :model-value="modelValue.includes(String(item.raw.value!))"
  131. color="primary"
  132. :hide-details="true"
  133. @click.stop="toggleItem(String(item.raw.value!))"
  134. />
  135. </template>
  136. <v-list-item-title>
  137. {{ item.raw.label }}
  138. </v-list-item-title>
  139. </v-list-item>
  140. </template>
  141. </template>
  142. </v-select>
  143. </template>
  144. <script setup lang="ts">
  145. import StringUtils from '~/services/utils/stringUtils'
  146. import _ from 'lodash'
  147. import { ref, computed, nextTick, type PropType, type Ref } from 'vue'
  148. import type { TreeSelectItem } from '~/types/layout'
  149. interface SelectItem {
  150. id: string
  151. label: string
  152. normalizedLabel?: string
  153. value?: number
  154. type: 'category' | 'subcategory' | 'item'
  155. parentId?: string
  156. level: number
  157. }
  158. const props = defineProps({
  159. modelValue: {
  160. type: Array as PropType<string[]>,
  161. required: true,
  162. },
  163. items: {
  164. type: Array as PropType<TreeSelectItem[]>,
  165. required: true,
  166. },
  167. maxVisibleChips: {
  168. type: Number,
  169. required: false,
  170. default: null,
  171. },
  172. nbLevel: {
  173. type: Number,
  174. required: false,
  175. default: 3,
  176. },
  177. /**
  178. * Label du champ
  179. * Si non défini, c'est le nom de propriété qui est utilisé
  180. */
  181. label: {
  182. type: String,
  183. required: false,
  184. default: null,
  185. },
  186. /**
  187. * @see https://vuetifyjs.com/en/api/v-autocomplete/#props-variant
  188. */
  189. variant: {
  190. type: String as PropType<
  191. | 'filled'
  192. | 'outlined'
  193. | 'plain'
  194. | 'underlined'
  195. | 'solo'
  196. | 'solo-inverted'
  197. | 'solo-filled'
  198. | undefined
  199. >,
  200. required: false,
  201. default: 'outlined',
  202. },
  203. })
  204. const searchInput = ref()
  205. /**
  206. * Force le focus sur l'input de recherche
  207. */
  208. const focusSearchInput = () => {
  209. nextTick(() => {
  210. if (searchInput.value?.$el) {
  211. const input = searchInput.value.$el.querySelector('input')
  212. if (input) {
  213. input.focus()
  214. }
  215. }
  216. })
  217. }
  218. /**
  219. * Gère les événements clavier pour éviter les conflits avec la navigation du menu
  220. */
  221. const onKeyDown = (event: KeyboardEvent) => {
  222. // Empêcher la propagation pour tous les caractères alphanumériques
  223. // et les touches spéciales de navigation
  224. if (
  225. event.key.length === 1 || // Caractères simples (a, c, etc.)
  226. ['Backspace', 'Delete', 'ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(
  227. event.key,
  228. )
  229. ) {
  230. event.stopPropagation()
  231. }
  232. // Empêcher également Escape de fermer le menu quand on est dans l'input
  233. if (event.key === 'Escape') {
  234. event.stopPropagation()
  235. // Optionnel : vider le texte de recherche
  236. if (searchText.value) {
  237. searchText.value = ''
  238. onSearchClear()
  239. }
  240. }
  241. }
  242. /**
  243. * A computed property that normalizes the labels of all items upfront.
  244. * This avoids having to normalize labels during each search operation.
  245. */
  246. const normalizedItems = computed(() => {
  247. return props.items.map((item) => ({
  248. ...item,
  249. normalizedLabel: StringUtils.normalize(item.label),
  250. }))
  251. })
  252. const emit = defineEmits(['update:modelValue'])
  253. const expandedCategories: Ref<Set<string>> = ref(new Set())
  254. const expandedSubcategories: Ref<Set<string>> = ref(new Set())
  255. const searchText: Ref<string> = ref('')
  256. /**
  257. * Expands all parent categories and subcategories of selected items.
  258. */
  259. const expandParentsOfSelectedItems = () => {
  260. expandedCategories.value.clear()
  261. expandedSubcategories.value.clear()
  262. for (const selectedId of props.modelValue) {
  263. const item = normalizedItems.value.find((i) => i.value === Number(selectedId))
  264. if (!item) continue
  265. let parentId = null
  266. if (props.nbLevel === 3) {
  267. // Trouver la sous-catégorie
  268. const subcategory = normalizedItems.value.find(
  269. (i) => i.id === item.parentId,
  270. )
  271. if (subcategory) {
  272. expandedSubcategories.value.add(subcategory.id)
  273. parentId = subcategory.parentId
  274. }
  275. } else {
  276. parentId = item.parentId
  277. }
  278. if (parentId) {
  279. // Trouver la catégorie
  280. const category = normalizedItems.value.find((i) => i.id === parentId)
  281. if (category) {
  282. expandedCategories.value.add(category.id)
  283. }
  284. }
  285. }
  286. }
  287. /**
  288. * A callback function that is triggered when the menu's open state is updated.
  289. */
  290. const onMenuUpdate = (isOpen: boolean) => {
  291. if (isOpen) {
  292. expandParentsOfSelectedItems()
  293. }
  294. // Réinitialiser la recherche quand le menu se ferme
  295. else if (searchText.value) {
  296. searchText.value = ''
  297. onSearchInput()
  298. }
  299. }
  300. /**
  301. * Toggles the expanded state of a given category. If the category is currently
  302. * expanded, it will collapse the category and also collapse its subcategories.
  303. * If the category is not expanded, it will expand the category.
  304. *
  305. * @param {string} categoryId - The unique identifier of the category to toggle.
  306. */
  307. const toggleCategory = (categoryId: string) => {
  308. if (expandedCategories.value.has(categoryId)) {
  309. expandedCategories.value.delete(categoryId)
  310. // Fermer aussi les sous-catégories
  311. const subcategories = normalizedItems.value.filter(
  312. (i) => i.parentId === categoryId && i.type === 'subcategory',
  313. )
  314. subcategories.forEach((sub) => {
  315. expandedSubcategories.value.delete(sub.id)
  316. })
  317. } else {
  318. expandedCategories.value.add(categoryId)
  319. }
  320. }
  321. /**
  322. * Toggles the expansion state of a subcategory.
  323. *
  324. * @param {string} subcategoryId - The unique identifier of the subcategory to be toggled.
  325. */
  326. const toggleSubcategory = (subcategoryId: string) => {
  327. if (expandedSubcategories.value.has(subcategoryId)) {
  328. expandedSubcategories.value.delete(subcategoryId)
  329. } else {
  330. expandedSubcategories.value.add(subcategoryId)
  331. }
  332. }
  333. /**
  334. * A function that toggles the inclusion of a specific value in
  335. * the selected items list.
  336. *
  337. * @param {string} value - The value to toggle in the selected items list.
  338. */
  339. const toggleItem = (value: string) => {
  340. const currentSelection = [...props.modelValue]
  341. const index = currentSelection.indexOf(value)
  342. if (index > -1) {
  343. currentSelection.splice(index, 1)
  344. } else {
  345. currentSelection.push(value)
  346. }
  347. emit('update:modelValue', currentSelection)
  348. }
  349. /**
  350. * Removes the specified item from the model value and emits an update event.
  351. *
  352. * @param {string} value - The item to be removed from the model value.
  353. * @emits update:modelValue - A custom event emitted with the updated model value
  354. * after the specified item has been removed.
  355. */
  356. const removeItem = (value: string) => {
  357. emit(
  358. 'update:modelValue',
  359. props.modelValue.filter((item) => item !== value),
  360. )
  361. }
  362. /**
  363. * Fonction appellée lorsque l'input de recherche textuelle est modifié
  364. */
  365. const onSearchInput = () => {
  366. // Réinitialiser les états d'expansion dans tous les cas
  367. expandedCategories.value.clear()
  368. expandedSubcategories.value.clear()
  369. if (searchText.value.trim()) {
  370. // Trouver tous les éléments qui correspondent à la recherche
  371. const matchingItems = normalizedItems.value.filter(
  372. (item) =>
  373. item.type === 'item' &&
  374. item.level === props.nbLevel - 1 &&
  375. itemMatchesSearch(item),
  376. )
  377. // Pour chaque élément correspondant, ajouter ses parents aux ensembles d'expansion
  378. for (const item of matchingItems) {
  379. let category
  380. if (props.nbLevel === 3) {
  381. // Trouver et ajouter la sous-catégorie parente
  382. const subcategory = normalizedItems.value.find(
  383. (i) => i.id === item.parentId,
  384. )
  385. if (subcategory) {
  386. expandedSubcategories.value.add(subcategory.id)
  387. // Trouver et ajouter la catégorie parente
  388. category = normalizedItems.value.find(
  389. (i) => i.id === subcategory.parentId,
  390. )
  391. }
  392. } else {
  393. // Trouver et ajouter la catégorie parente
  394. category = normalizedItems.value.find((i) => i.id === item.parentId)
  395. }
  396. if (category) {
  397. expandedCategories.value.add(category.id)
  398. }
  399. }
  400. }
  401. }
  402. const onSearchInputDebounced = _.debounce(onSearchInput, 200)
  403. const onSearchClear = () => {
  404. searchText.value = ''
  405. onSearchInput()
  406. }
  407. /**
  408. * Checks if any word in the normalized text starts with the normalized search term.
  409. *
  410. * @param {string} normalizedText - The normalized text to check.
  411. * @param {string} normalizedSearch - The normalized search term.
  412. * @returns {boolean} `true` if any word in the text starts with the search term; otherwise, `false`.
  413. */
  414. const anyWordStartsWith = (
  415. normalizedText: string,
  416. normalizedSearch: string,
  417. ): boolean => {
  418. if (normalizedText.indexOf(normalizedSearch) === 0) return true
  419. const spaceIndex = normalizedText.indexOf(' ')
  420. if (spaceIndex === -1) return false
  421. return normalizedText
  422. .split(' ')
  423. .some((word) => word.startsWith(normalizedSearch))
  424. }
  425. /**
  426. * Determines if a given item matches the current search text by checking its normalized label
  427. * and, for certain items, the normalized labels of its parent elements.
  428. *
  429. * The search text is normalized using `StringUtils.normalize` before comparison.
  430. * If no search text is provided, the item matches by default.
  431. *
  432. * For items of type `item` at level 2, the function checks:
  433. * - The normalized label of the item itself
  434. * - The normalized label of its parent subcategory
  435. * - The normalized label of the grandparent category (if applicable)
  436. *
  437. * For all other item types, only the item's normalized label is checked.
  438. *
  439. * The matching is done by checking if any word in the normalized label starts with the normalized search text.
  440. *
  441. * @param {TreeSelectItem} item - The item to evaluate against the search text.
  442. * @returns {boolean} `true` if the item or its relevant parent(s) match the search text; otherwise, `false`.
  443. */
  444. const itemMatchesSearch = (item: TreeSelectItem): boolean => {
  445. if (!searchText.value) return true
  446. const normalizedSearch = StringUtils.normalize(searchText.value)
  447. // Find the item with normalized label from our computed property
  448. const itemWithNormalizedLabel = normalizedItems.value.find(
  449. (i) => i.id === item.id,
  450. )
  451. if (!itemWithNormalizedLabel) return false
  452. // Si c'est un élément de niveau nbLevel - 1, vérifier son label et les labels de ses parents
  453. if (item.type === 'item' && item.level === props.nbLevel - 1) {
  454. // Vérifier le label de l'élément
  455. if (
  456. anyWordStartsWith(
  457. itemWithNormalizedLabel.normalizedLabel!,
  458. normalizedSearch,
  459. )
  460. )
  461. return true
  462. let parentId = item.parentId
  463. if (props.nbLevel === 3) {
  464. // Trouver et vérifier le label de la sous-catégorie parente
  465. const subcategory = normalizedItems.value.find((i) => i.id === parentId)
  466. if (
  467. subcategory &&
  468. anyWordStartsWith(subcategory.normalizedLabel!, normalizedSearch)
  469. )
  470. return true
  471. // Trouver et vérifier le label de la catégorie parente
  472. if (subcategory && subcategory.parentId) {
  473. parentId = subcategory.parentId
  474. }
  475. }
  476. // Trouver et vérifier le label de la catégorie parente
  477. const category = normalizedItems.value.find((i) => i.id === parentId)
  478. if (
  479. category &&
  480. anyWordStartsWith(category.normalizedLabel!, normalizedSearch)
  481. )
  482. return true
  483. return false
  484. }
  485. // Pour les autres types d'éléments, vérifier simplement leur label
  486. return anyWordStartsWith(
  487. itemWithNormalizedLabel.normalizedLabel!,
  488. normalizedSearch,
  489. )
  490. }
  491. /**
  492. * Filtre les éléments de niveau 2 qui correspondent au texte de recherche.
  493. *
  494. * @returns {TreeSelectItem[]} Les éléments de niveau 2 qui correspondent à la recherche.
  495. */
  496. const findMatchingLevel2Items = (): TreeSelectItem[] => {
  497. return normalizedItems.value.filter(
  498. (item) =>
  499. item.type === 'item' &&
  500. item.level === props.nbLevel - 1 &&
  501. itemMatchesSearch(item),
  502. )
  503. }
  504. /**
  505. * Construit une liste hiérarchique d'éléments basée sur les résultats de recherche.
  506. * Pour chaque élément correspondant, ajoute sa hiérarchie complète (catégorie et sous-catégorie).
  507. *
  508. * @param {TreeSelectItem[]} matchingItems - Les éléments correspondant à la recherche.
  509. * @returns {TreeSelectItem[]} Liste hiérarchique incluant les éléments et leurs parents.
  510. */
  511. const buildSearchResultsList = (
  512. matchingItems: TreeSelectItem[],
  513. ): TreeSelectItem[] => {
  514. const result: TreeSelectItem[] = []
  515. const addedCategoryIds = new Set<string>()
  516. const addedSubcategoryIds = new Set<string>()
  517. for (const item of matchingItems) {
  518. let parentId = item.parentId
  519. if (props.nbLevel === 3) {
  520. // Trouver la sous-catégorie parente
  521. const subcategory = normalizedItems.value.find((i) => i.id === parentId)
  522. if (!subcategory) continue
  523. // Ajouter la sous-catégorie si elle n'est pas déjà présente
  524. if (!addedSubcategoryIds.has(subcategory.id)) {
  525. result.push(subcategory)
  526. addedSubcategoryIds.add(subcategory.id)
  527. expandedSubcategories.value.add(subcategory.id)
  528. }
  529. parentId = subcategory.parentId
  530. }
  531. // Trouver la catégorie parente
  532. const category = normalizedItems.value.find((i) => i.id === parentId)
  533. if (!category) continue
  534. // Ajouter la catégorie si elle n'est pas déjà présente
  535. if (!addedCategoryIds.has(category.id)) {
  536. result.push(category)
  537. addedCategoryIds.add(category.id)
  538. expandedCategories.value.add(category.id)
  539. }
  540. // Ajouter l'élément
  541. result.push(item)
  542. }
  543. return result
  544. }
  545. /**
  546. * Traite récursivement les éléments pour construire une liste hiérarchique
  547. * basée sur l'état d'expansion des catégories et sous-catégories.
  548. *
  549. * @param {TreeSelectItem[]} items - Les éléments à traiter.
  550. * @param {TreeSelectItem[]} result - Le tableau résultat à remplir.
  551. * @param {boolean} parentExpanded - Indique si le parent est développé.
  552. */
  553. const processItemsRecursively = (
  554. items: TreeSelectItem[],
  555. result: TreeSelectItem[],
  556. parentExpanded = true,
  557. ): void => {
  558. for (const item of items) {
  559. if (item.type === 'category') {
  560. result.push(item)
  561. if (expandedCategories.value.has(item.id)) {
  562. const subcategories = normalizedItems.value.filter(
  563. (i) =>
  564. i.parentId === item.id &&
  565. ((props.nbLevel == 2 && i.type === 'item') ||
  566. (props.nbLevel == 3 && i.type === 'subcategory')),
  567. )
  568. processItemsRecursively(subcategories, result, true)
  569. }
  570. } else if (item.type === 'subcategory') {
  571. if (parentExpanded) {
  572. result.push(item)
  573. if (expandedSubcategories.value.has(item.id)) {
  574. const subItems = normalizedItems.value.filter(
  575. (i) => i.parentId === item.id && i.type === 'item',
  576. )
  577. processItemsRecursively(subItems, result, true)
  578. }
  579. }
  580. } else if (item.type === 'item' && parentExpanded) {
  581. result.push(item)
  582. }
  583. }
  584. }
  585. /**
  586. * Construit une liste hiérarchique d'éléments en mode normal (sans recherche).
  587. *
  588. * @returns {TreeSelectItem[]} Liste hiérarchique basée sur l'état d'expansion.
  589. */
  590. const buildNormalModeList = (): TreeSelectItem[] => {
  591. const result: TreeSelectItem[] = []
  592. const topLevelItems = normalizedItems.value.filter((item) => !item.parentId)
  593. processItemsRecursively(topLevelItems, result)
  594. return result
  595. }
  596. /**
  597. * A computed property that generates a flattened and organized list of items
  598. * from a hierarchical structure, based on the current search text and
  599. * expanded categories/subcategories.
  600. *
  601. * @returns {TreeSelectItem[]} Flattened and organized list of items.
  602. */
  603. const flattenedItems = computed(() => {
  604. const hasSearch = !!searchText.value.trim()
  605. if (hasSearch) {
  606. const matchingItems = findMatchingLevel2Items()
  607. return buildSearchResultsList(matchingItems)
  608. }
  609. return buildNormalModeList()
  610. })
  611. /**
  612. * A computed property that maps selected values to their corresponding labels.
  613. * This is used to display the correct labels in the chips when the dropdown is closed.
  614. *
  615. * @returns {Record<string, string>} A map of selected values to their labels.
  616. */
  617. const selectedItemsMap = computed(() => {
  618. const map: Record<string, string> = {}
  619. // Find all selectable items (type 'item') in the items array with normalized labels
  620. const selectableItems = normalizedItems.value.filter(
  621. (item) => item.type === 'item' && item.value,
  622. )
  623. // Create a map of values to labels
  624. selectableItems.forEach((item) => {
  625. if (item.value) {
  626. map[item.value] = item.label
  627. }
  628. })
  629. return map
  630. })
  631. </script>
  632. <style scoped lang="scss">
  633. .v-list-item--active {
  634. background-color: rgba(var(--v-theme-primary), 0.1);
  635. }
  636. .v-list-item {
  637. contain: layout style paint;
  638. height: 40px !important; /* Ensure consistent height for virtual scrolling */
  639. min-height: 40px !important;
  640. max-height: 40px !important;
  641. padding-top: 0 !important;
  642. padding-bottom: 0 !important;
  643. }
  644. .search-icon {
  645. color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
  646. }
  647. :deep(.v-field__prepend-inner) {
  648. padding-top: 0;
  649. }
  650. :deep(.v-list) {
  651. padding-top: 0;
  652. contain: content;
  653. will-change: transform;
  654. transform-style: preserve-3d;
  655. }
  656. </style>