adata-ui 2.1.40-beta → 2.1.40-beta.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/components/elements/a-select-row/ASelectRowV2.vue +214 -0
  2. package/components/elements/button/AButtonV2.vue +112 -0
  3. package/components/elements/leave-note/ALeaveNote.vue +38 -17
  4. package/components/elements/segmented/ASegmentedV2.vue +58 -0
  5. package/components/elements/select/ASelectV2.vue +589 -0
  6. package/components/elements/show-more/AShowMoreV2.vue +26 -0
  7. package/components/elements/star-rating/AStarRating.vue +12 -4
  8. package/components/elements/status-badge-v2/StatusBadgeV2.vue +78 -0
  9. package/components/features/pk-mobile-services/APkMobileServices.vue +5 -27
  10. package/components/forms/checkbox/ACheckboxV2.vue +229 -0
  11. package/components/forms/feedback-form/FeedbackForm.vue +8 -8
  12. package/components/forms/input/AInputV2.vue +542 -0
  13. package/components/forms/input/textarea/ATextareaV2.vue +248 -0
  14. package/components/forms/toggle/AToggleV2.vue +71 -0
  15. package/components/modals/AnotherDeviceModal.vue +22 -18
  16. package/components/modals/ApplicationAcceptedModal.vue +12 -15
  17. package/components/modals/ConnectingTariffModal.vue +13 -20
  18. package/components/modals/Insufficient-funds-modal.vue +18 -33
  19. package/components/modals/LimitReachedModal.vue +4 -3
  20. package/components/modals/NoAccessModal.vue +9 -11
  21. package/components/modals/PaymentMethodModal.vue +1 -1
  22. package/components/modals/ReportBugConfirmModal.vue +12 -10
  23. package/components/modals/ReportBugModal.vue +42 -36
  24. package/components/modals/Resend.vue +9 -10
  25. package/components/modals/SubmitApplicationModal.vue +27 -24
  26. package/components/modals/id/IdConfirmAccountOtpModal.vue +4 -9
  27. package/components/modals/id/IdLoginModal.vue +3 -8
  28. package/components/modals/id/IdModals.vue +23 -10
  29. package/components/modals/id/IdTwoFactorModal.vue +3 -8
  30. package/components/navigation/header/AHeader.vue +5 -5
  31. package/components/navigation/header/HeaderLink.vue +15 -21
  32. package/components/navigation/header/NavList.vue +18 -62
  33. package/components/navigation/header/ProductMenu.vue +55 -47
  34. package/components/navigation/pill-tabs/APillTabsV2.vue +118 -0
  35. package/components/overlays/modal/AModalV2.vue +388 -0
  36. package/composables/useAccessTokenCookie.ts +25 -0
  37. package/composables/useActiveNavigation.ts +84 -0
  38. package/composables/useChipOverflow.ts +92 -0
  39. package/composables/useHeaderNavigationLinks.ts +53 -23
  40. package/icons/arrow/arrow-right.vue +14 -0
  41. package/icons/briefcase.vue +14 -0
  42. package/icons/car-side.vue +16 -0
  43. package/icons/cart.vue +15 -0
  44. package/icons/download-rounded.vue +15 -0
  45. package/icons/shield-verified.vue +14 -0
  46. package/lang/en.ts +34 -4
  47. package/lang/kk.ts +35 -5
  48. package/lang/ru.ts +36 -6
  49. package/package.json +1 -1
  50. package/shared/constans/pages.ts +8 -2
@@ -0,0 +1,388 @@
1
+ <script setup lang="ts">
2
+ import { onKeyStroke, useScrollLock, useWindowSize } from '@vueuse/core'
3
+
4
+ type ModalSize = 'sm' | 'md' | 'lg' | 'xl' | 'container' | 'fullscreen'
5
+
6
+ const props = withDefaults(defineProps<{
7
+ title?: string
8
+ size?: ModalSize
9
+ width?: string
10
+ closeOnOverlay?: boolean
11
+ closeOnEsc?: boolean
12
+ lockScroll?: boolean
13
+ persistent?: boolean
14
+ transition?: boolean
15
+ contentClass?: string
16
+ hideHeader?: boolean
17
+ blur?: boolean | 'sm' | 'md' | 'lg' | 'xl'
18
+ mobileSheet?: boolean
19
+ swipeToClose?: boolean
20
+ swipeThreshold?: number
21
+ showDragHandle?: boolean
22
+ }>(), {
23
+ size: 'md',
24
+ closeOnOverlay: true,
25
+ closeOnEsc: true,
26
+ lockScroll: true,
27
+ persistent: false,
28
+ transition: true,
29
+ hideHeader: false,
30
+ blur: false,
31
+ mobileSheet: true,
32
+ swipeToClose: true,
33
+ swipeThreshold: 65,
34
+ showDragHandle: true,
35
+ })
36
+
37
+ const emit = defineEmits<{
38
+ (e: 'close'): void
39
+ (e: 'open'): void
40
+ }>()
41
+
42
+ const open = defineModel<boolean>({ required: true })
43
+
44
+ const contentRef = ref<HTMLElement | null>(null)
45
+ const overlayRef = ref<HTMLElement | null>(null)
46
+ const swipeClosing = ref(false)
47
+ const bodyScrollLocked = useScrollLock(import.meta.client ? document.body : null)
48
+ const { width: windowWidth } = useWindowSize()
49
+
50
+ const isMobile = computed(() => windowWidth.value < 1025)
51
+ const isSheet = computed(() => isMobile.value && props.mobileSheet)
52
+
53
+ const sizeStyle = computed(() => {
54
+ if (isSheet.value) return { width: '100%' }
55
+ if (props.width) return { width: props.width }
56
+
57
+ switch (props.size) {
58
+ case 'sm': return { width: '380px' }
59
+ case 'md': return { width: '520px' }
60
+ case 'lg': return { width: '720px' }
61
+ case 'xl': return { width: '1100px' }
62
+ case 'container': return {}
63
+ case 'fullscreen': return { width: '100vw', height: '100vh' }
64
+ default: return { width: '520px' }
65
+ }
66
+ })
67
+
68
+ const blurClass = computed(() => {
69
+ if (!props.blur) return ''
70
+
71
+ switch (props.blur) {
72
+ case 'sm': return 'backdrop-blur-sm'
73
+ case 'md': return 'backdrop-blur-md'
74
+ case 'lg': return 'backdrop-blur-lg'
75
+ case 'xl': return 'backdrop-blur-xl'
76
+ default: return 'backdrop-blur'
77
+ }
78
+ })
79
+
80
+ const overlayClass = computed(() => {
81
+ const base = ['modal-v2-overlay fixed inset-0 z-[10000] flex bg-black/40 dark:bg-black/60']
82
+
83
+ if (isSheet.value) {
84
+ base.push('items-end justify-stretch')
85
+ }
86
+ else {
87
+ base.push(props.size === 'container' ? 'items-center justify-center' : 'items-center justify-center p-4')
88
+ }
89
+
90
+ if (blurClass.value) base.push(blurClass.value)
91
+
92
+ return base.join(' ')
93
+ })
94
+
95
+ const contentBaseClass = computed(() => {
96
+ const base = ['modal-v2-content flex flex-col overflow-hidden border bg-white shadow-2xl dark:bg-gray-900']
97
+
98
+ if (isSheet.value) {
99
+ base.push('w-full max-w-none max-h-[100dvh] rounded-t-2xl rounded-b-none border-x-0 border-b-0 border-t border-gray-200 dark:border-gray-800')
100
+ }
101
+ else {
102
+ const radius = props.size === 'fullscreen' ? 'rounded-none' : 'rounded-2xl'
103
+ const widthCls = props.size === 'container'
104
+ ? ' w-full max-w-screen-sm md:max-w-screen-md lg:max-w-screen-lg xl:max-w-screen-xl 2xl:max-w-screen-2xl'
105
+ : ''
106
+ base.push(`max-h-[calc(100vh-2rem)] border-gray-200 dark:border-gray-800 ${radius}${widthCls}`)
107
+ }
108
+
109
+ return base.join(' ')
110
+ })
111
+
112
+ const overlayTransitionName = computed(() => {
113
+ if (swipeClosing.value) return ''
114
+ return props.transition ? 'modal-v2-overlay' : ''
115
+ })
116
+ const contentTransitionName = computed(() => {
117
+ if (swipeClosing.value || !props.transition) return ''
118
+ return isSheet.value ? 'modal-v2-sheet' : 'modal-v2-content'
119
+ })
120
+
121
+ function closeIfAllowed() {
122
+ if (props.persistent) return
123
+ open.value = false
124
+ }
125
+
126
+ function onOverlayClick(event: MouseEvent) {
127
+ if (!props.closeOnOverlay || props.persistent) return
128
+ if (event.target === event.currentTarget) {
129
+ open.value = false
130
+ }
131
+ }
132
+
133
+ onKeyStroke('Escape', () => {
134
+ if (!open.value) return
135
+ if (!props.closeOnEsc || props.persistent) return
136
+ open.value = false
137
+ })
138
+
139
+ function setScrollbarCompensation(active: boolean) {
140
+ if (!import.meta.client) return
141
+
142
+ if (active) {
143
+ const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth
144
+ document.body.style.paddingRight = scrollbarWidth > 0 ? `${scrollbarWidth}px` : ''
145
+ }
146
+ else {
147
+ document.body.style.paddingRight = ''
148
+ }
149
+ }
150
+
151
+ watch(open, (value) => {
152
+ if (props.lockScroll) {
153
+ if (value) setScrollbarCompensation(true)
154
+ bodyScrollLocked.value = value
155
+ if (!value) setScrollbarCompensation(false)
156
+ }
157
+
158
+ if (value) emit('open')
159
+ else emit('close')
160
+ })
161
+
162
+ onBeforeUnmount(() => {
163
+ if (bodyScrollLocked.value) {
164
+ bodyScrollLocked.value = false
165
+ }
166
+ setScrollbarCompensation(false)
167
+ })
168
+
169
+ const dragging = ref(false)
170
+ const dragStartY = ref(0)
171
+ const dragDelta = ref(0)
172
+
173
+ function canSwipe() {
174
+ return isSheet.value && props.swipeToClose && !props.persistent
175
+ }
176
+
177
+ function setTransform(value: string) {
178
+ if (contentRef.value) {
179
+ contentRef.value.style.transform = value
180
+ }
181
+ }
182
+
183
+ function onTouchStart(event: TouchEvent) {
184
+ if (!canSwipe()) return
185
+ dragging.value = true
186
+ dragStartY.value = event.touches[0].clientY
187
+ dragDelta.value = 0
188
+
189
+ if (contentRef.value) {
190
+ contentRef.value.style.transition = 'none'
191
+ }
192
+ }
193
+
194
+ function onTouchMove(event: TouchEvent) {
195
+ if (!dragging.value || !canSwipe()) return
196
+
197
+ const delta = event.touches[0].clientY - dragStartY.value
198
+ if (delta < 0) {
199
+ dragDelta.value = 0
200
+ setTransform('')
201
+ return
202
+ }
203
+
204
+ dragDelta.value = delta
205
+ setTransform(`translateY(${delta}px)`)
206
+ }
207
+
208
+ function prefersReducedMotion() {
209
+ return import.meta.client && window.matchMedia('(prefers-reduced-motion: reduce)').matches
210
+ }
211
+
212
+ function animateClose() {
213
+ const content = contentRef.value
214
+
215
+ if (!content || prefersReducedMotion()) {
216
+ open.value = false
217
+ return
218
+ }
219
+
220
+ swipeClosing.value = true
221
+
222
+ let done = false
223
+ const finish = () => {
224
+ if (done) return
225
+ done = true
226
+ content.removeEventListener('transitionend', onTransitionEnd)
227
+ open.value = false
228
+ swipeClosing.value = false
229
+ }
230
+
231
+ function onTransitionEnd(event: TransitionEvent) {
232
+ if (event.target === content && event.propertyName === 'transform') {
233
+ finish()
234
+ }
235
+ }
236
+
237
+ content.addEventListener('transitionend', onTransitionEnd)
238
+ setTimeout(finish, 320)
239
+
240
+ requestAnimationFrame(() => {
241
+ content.style.transition = 'transform 260ms cubic-bezier(0.22, 1, 0.36, 1)'
242
+ content.style.transform = 'translateY(100%)'
243
+
244
+ if (overlayRef.value) {
245
+ overlayRef.value.style.transition = 'opacity 260ms ease'
246
+ overlayRef.value.style.opacity = '0'
247
+ }
248
+ })
249
+ }
250
+
251
+ function onTouchEnd() {
252
+ if (!dragging.value) return
253
+ dragging.value = false
254
+
255
+ const shouldClose = dragDelta.value > props.swipeThreshold && canSwipe()
256
+
257
+ if (shouldClose) {
258
+ animateClose()
259
+ }
260
+ else {
261
+ if (contentRef.value) {
262
+ contentRef.value.style.transition = 'transform 220ms cubic-bezier(0.22, 1, 0.36, 1)'
263
+ contentRef.value.style.transform = ''
264
+ }
265
+ }
266
+
267
+ dragDelta.value = 0
268
+ }
269
+ </script>
270
+
271
+ <template>
272
+ <client-only>
273
+ <teleport to="body">
274
+ <transition :name="overlayTransitionName">
275
+ <div
276
+ v-if="open"
277
+ ref="overlayRef"
278
+ :class="overlayClass"
279
+ @mousedown="onOverlayClick"
280
+ >
281
+ <transition :name="contentTransitionName" appear>
282
+ <div
283
+ v-if="open"
284
+ ref="contentRef"
285
+ :class="contentBaseClass"
286
+ :style="sizeStyle"
287
+ @mousedown.stop
288
+ >
289
+ <div
290
+ v-if="isSheet && showDragHandle"
291
+ class="flex shrink-0 cursor-grab touch-none justify-center py-2"
292
+ @touchstart.passive="onTouchStart"
293
+ @touchmove="onTouchMove"
294
+ @touchend="onTouchEnd"
295
+ @touchcancel="onTouchEnd"
296
+ >
297
+ <div class="h-1 w-10 rounded-full bg-gray-300 dark:bg-gray-600" />
298
+ </div>
299
+
300
+ <header
301
+ v-if="!hideHeader"
302
+ class="flex shrink-0 items-center justify-between gap-3 border-b border-gray-200 px-5 py-4 dark:border-gray-800"
303
+ @touchstart.passive="onTouchStart"
304
+ @touchmove="onTouchMove"
305
+ @touchend="onTouchEnd"
306
+ @touchcancel="onTouchEnd"
307
+ >
308
+ <slot name="header">
309
+ <h2 v-if="title" class="text-base font-semibold text-gray-900 dark:text-gray-100">
310
+ {{ title }}
311
+ </h2>
312
+ </slot>
313
+ <button
314
+ type="button"
315
+ class="ml-auto rounded-md p-1 transition hover:bg-gray-100 dark:hover:bg-gray-800 dark:hover:text-gray-200"
316
+ @click="closeIfAllowed"
317
+ >
318
+ <a-icon-x-mark class="size-4" />
319
+ </button>
320
+ </header>
321
+
322
+ <div class="min-h-0 flex-1 overflow-auto" :class="contentClass">
323
+ <slot :close="closeIfAllowed" />
324
+ </div>
325
+
326
+ <footer
327
+ v-if="$slots.footer"
328
+ class="shrink-0 border-t border-gray-200 px-5 py-4 dark:border-gray-800"
329
+ >
330
+ <slot name="footer" :close="closeIfAllowed" />
331
+ </footer>
332
+ </div>
333
+ </transition>
334
+ </div>
335
+ </transition>
336
+ </teleport>
337
+ </client-only>
338
+ </template>
339
+
340
+ <style scoped lang="scss">
341
+ .modal-v2-overlay-enter-active {
342
+ transition: opacity 200ms ease-out;
343
+ }
344
+
345
+ .modal-v2-overlay-leave-active {
346
+ transition: opacity 150ms ease-in;
347
+ }
348
+
349
+ .modal-v2-overlay-enter-from,
350
+ .modal-v2-overlay-leave-to {
351
+ opacity: 0;
352
+ }
353
+
354
+ .modal-v2-content-enter-active {
355
+ transition: transform 220ms cubic-bezier(0.22, 1, 0.36, 1), opacity 220ms ease-out;
356
+ }
357
+
358
+ .modal-v2-content-leave-active {
359
+ transition: transform 150ms ease-in, opacity 150ms ease-in;
360
+ }
361
+
362
+ .modal-v2-content-enter-from,
363
+ .modal-v2-content-leave-to {
364
+ opacity: 0;
365
+ transform: translateY(8px) scale(0.98);
366
+ }
367
+
368
+ .modal-v2-sheet-enter-active,
369
+ .modal-v2-sheet-leave-active {
370
+ transition: transform 280ms cubic-bezier(0.22, 1, 0.36, 1);
371
+ }
372
+
373
+ .modal-v2-sheet-enter-from,
374
+ .modal-v2-sheet-leave-to {
375
+ transform: translateY(100%);
376
+ }
377
+
378
+ @media (prefers-reduced-motion: reduce) {
379
+ .modal-v2-overlay-enter-active,
380
+ .modal-v2-overlay-leave-active,
381
+ .modal-v2-content-enter-active,
382
+ .modal-v2-content-leave-active,
383
+ .modal-v2-sheet-enter-active,
384
+ .modal-v2-sheet-leave-active {
385
+ transition: none;
386
+ }
387
+ }
388
+ </style>
@@ -0,0 +1,25 @@
1
+ import { useUrls } from '#adata-ui/composables/useUrls'
2
+
3
+ /**
4
+ * Единая точка записи куки accessToken (логин, 2FA, OTP, обмен по auth-сессии).
5
+ * Домен должен совпадать с доменом удаления в resetTokens (adata-common-auth),
6
+ * иначе кука не находится на следующей загрузке и логин-флоу зацикливается на reload.
7
+ */
8
+ export async function writeAccessTokenCookie(accessToken: string, expireIn?: number): Promise<boolean> {
9
+ const { mode } = useUrls()
10
+ const sharedDomain = `${mode}.kz`
11
+ const onSharedDomain = location.hostname === sharedDomain || location.hostname.endsWith(`.${sharedDomain}`)
12
+ const maxAge = Number(expireIn) > 0 ? Number(expireIn) : undefined
13
+
14
+ useCookie('accessToken', {
15
+ maxAge,
16
+ // на localhost/превью-хостах атрибут domain невалиден — пишем host-only куку
17
+ domain: onSharedDomain ? `.${sharedDomain}` : undefined,
18
+ path: '/',
19
+ secure: location.protocol === 'https:',
20
+ }).value = accessToken
21
+
22
+ // useCookie пишет document.cookie на следующем flush, не синхронно
23
+ await nextTick()
24
+ return document.cookie.split('; ').some(part => part.startsWith('accessToken='))
25
+ }
@@ -0,0 +1,84 @@
1
+ // Active-state detection for the products mega-menu.
2
+ //
3
+ // Two levels of "active":
4
+ // 1. activeModule — the current host matches one of the module's hosts
5
+ // (host ≠ module key: e.g. the `fines` module lives on avto.*.kz,
6
+ // and `tenders` spans both zakupki.*.kz and tender.*.kz).
7
+ // 2. activeService — within the active module, the current page matches a
8
+ // concrete item link (`/`, `/unload`, `/foreign`, …).
9
+
10
+ const LOCALE_SEGMENTS = new Set(['kk', 'en'])
11
+
12
+ interface NavTarget { to: string }
13
+ interface NavModule { link: string, items: NavTarget[] }
14
+ interface NormalizedUrl { host: string, key: string }
15
+
16
+ function safeUrl(href: string): URL | null {
17
+ try {
18
+ return new URL(href)
19
+ }
20
+ catch {
21
+ return null
22
+ }
23
+ }
24
+
25
+ // Reduce a URL to a comparable identity: host + locale-stripped path + query.
26
+ // The query is kept because it can be significant (fines main is `/?check_fines=true`).
27
+ function normalize(href: string): NormalizedUrl | null {
28
+ const url = safeUrl(href)
29
+ if (!url) return null
30
+
31
+ const segments = url.pathname
32
+ .split('/')
33
+ .filter(segment => segment && !LOCALE_SEGMENTS.has(segment))
34
+
35
+ const path = `/${segments.join('/')}`
36
+ return { host: url.host, key: path + url.search }
37
+ }
38
+
39
+ // A "root" target has no path segments (e.g. `/` or `/?check_fines=true`).
40
+ // Roots must match exactly, otherwise their prefix would light up every sub-page.
41
+ function isRoot(key: string): boolean {
42
+ return key === '/' || key.startsWith('/?')
43
+ }
44
+
45
+ export function useActiveNavigation() {
46
+ const route = useRoute()
47
+
48
+ // Recompute on every SPA navigation. Host stays constant within an app, so we
49
+ // read it from the request URL (SSR-safe); the path/query come from the route.
50
+ const current = computed<NormalizedUrl | null>(() => {
51
+ const host = useRequestURL().host
52
+ return normalize(`https://${host}${route.fullPath}`)
53
+ })
54
+
55
+ function isActiveModule(module: NavModule): boolean {
56
+ if (!current.value) return false
57
+
58
+ const hosts = new Set<string>()
59
+ const link = normalize(module.link)
60
+ if (link) hosts.add(link.host)
61
+ for (const item of module.items) {
62
+ const item_url = normalize(item.to)
63
+ if (item_url) hosts.add(item_url.host)
64
+ }
65
+
66
+ return hosts.has(current.value.host)
67
+ }
68
+
69
+ function isActiveService(itemTo: string): boolean {
70
+ const cur = current.value
71
+ const item = normalize(itemTo)
72
+ if (!cur || !item || cur.host !== item.host) return false
73
+
74
+ // The car-check page redirects to a `car-result` URL; keep its item lit.
75
+ if (item.key.includes('check-car') && cur.key.includes('car-result')) return true
76
+
77
+ if (cur.key === item.key) return true
78
+ if (isRoot(item.key)) return false
79
+
80
+ return cur.key.startsWith(item.key)
81
+ }
82
+
83
+ return { isActiveModule, isActiveService }
84
+ }
@@ -0,0 +1,92 @@
1
+ import type { ComputedRef, Ref } from 'vue'
2
+
3
+ interface UseChipOverflowOptions {
4
+ container: Ref<HTMLElement | null>
5
+ measure: Ref<HTMLElement | null>
6
+ count: Ref<number>
7
+ contentKey?: Ref<unknown>
8
+ gap?: number
9
+ }
10
+
11
+ interface UseChipOverflow {
12
+ visibleCount: Ref<number>
13
+ hiddenCount: ComputedRef<number>
14
+ }
15
+
16
+ export function useChipOverflow(options: UseChipOverflowOptions): UseChipOverflow {
17
+ const { container, measure, count, contentKey, gap = 6 } = options
18
+
19
+ const availableWidth = ref(0)
20
+ const visibleCount = ref(0)
21
+
22
+ // Ceil so a chip is only counted as fitting when it fully fits;
23
+ // sub-pixel underestimates would let flexbox squeeze visible chips into truncation.
24
+ function chipWidth(el: HTMLElement): number {
25
+ return Math.ceil(el.getBoundingClientRect().width)
26
+ }
27
+
28
+ function recompute() {
29
+ const total = count.value
30
+ if (total <= 0) {
31
+ visibleCount.value = 0
32
+ return
33
+ }
34
+
35
+ const avail = availableWidth.value
36
+ const root = measure.value
37
+
38
+ if (!avail || !root) {
39
+ visibleCount.value = total
40
+ return
41
+ }
42
+
43
+ const chips = (Array.from(root.children) as HTMLElement[]).slice(0, total)
44
+
45
+ let used = 0
46
+ let fit = 0
47
+ for (let i = 0; i < chips.length; i++) {
48
+ const width = chipWidth(chips[i]) + (i > 0 ? gap : 0)
49
+ if (used + width > avail) break
50
+ used += width
51
+ fit++
52
+ }
53
+
54
+ if (fit >= total) {
55
+ visibleCount.value = total
56
+ return
57
+ }
58
+
59
+ const badge = root.children[total] as HTMLElement | undefined
60
+ const badgeWidth = (badge ? chipWidth(badge) : 0) + gap
61
+ while (fit > 1 && used + badgeWidth > avail) {
62
+ used -= chipWidth(chips[fit - 1]) + gap
63
+ fit--
64
+ }
65
+
66
+ visibleCount.value = Math.max(1, fit)
67
+ }
68
+
69
+ let resizeObserver: ResizeObserver | null = null
70
+
71
+ onMounted(() => {
72
+ if (container.value) {
73
+ availableWidth.value = Math.floor(container.value.clientWidth)
74
+ resizeObserver = new ResizeObserver((entries) => {
75
+ availableWidth.value = Math.floor(entries[0]?.contentRect.width ?? 0)
76
+ })
77
+ resizeObserver.observe(container.value)
78
+ }
79
+ recompute()
80
+ })
81
+
82
+ onUnmounted(() => resizeObserver?.disconnect())
83
+
84
+ watch(
85
+ contentKey ? [availableWidth, count, contentKey] : [availableWidth, count],
86
+ () => nextTick(recompute),
87
+ )
88
+
89
+ const hiddenCount = computed(() => Math.max(0, count.value - visibleCount.value))
90
+
91
+ return { visibleCount, hiddenCount }
92
+ }