@dnax/ui 0.0.1
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.
- package/README.md +15 -0
- package/components/QAccordion.vue +68 -0
- package/components/QAccordionContent.vue +62 -0
- package/components/QAccordionItem.vue +24 -0
- package/components/QAccordionTrigger.vue +51 -0
- package/components/QActionSheet.vue +159 -0
- package/components/QApp.vue +8 -0
- package/components/QAutocomplete.vue +316 -0
- package/components/QAvatar.vue +92 -0
- package/components/QBackHeader.vue +69 -0
- package/components/QBackTop.vue +125 -0
- package/components/QBadge.vue +55 -0
- package/components/QBottomSheet.vue +177 -0
- package/components/QBottomSheetFooter.vue +9 -0
- package/components/QBottomSheetHeader.vue +35 -0
- package/components/QBottomSheetProvider.vue +62 -0
- package/components/QBottomSheetTrigger.vue +18 -0
- package/components/QBtn.vue +144 -0
- package/components/QBtnGroup.vue +31 -0
- package/components/QBubble.vue +36 -0
- package/components/QBubbleContent.vue +28 -0
- package/components/QBubbleGroup.vue +25 -0
- package/components/QBubbleReactions.vue +40 -0
- package/components/QCard.vue +63 -0
- package/components/QCardActions.vue +27 -0
- package/components/QCardContent.vue +29 -0
- package/components/QCarousel.vue +115 -0
- package/components/QCarouselContent.vue +23 -0
- package/components/QCarouselItem.vue +20 -0
- package/components/QCarouselNav.vue +51 -0
- package/components/QCheckbox.vue +146 -0
- package/components/QChip.vue +91 -0
- package/components/QCircularProgress.vue +122 -0
- package/components/QCol.vue +52 -0
- package/components/QCollapse.vue +141 -0
- package/components/QConfigProvider.vue +164 -0
- package/components/QContainer.vue +201 -0
- package/components/QCountDown.vue +164 -0
- package/components/QCountryPicker.vue +593 -0
- package/components/QDataGrid.vue +491 -0
- package/components/QDatePicker.vue +250 -0
- package/components/QDialog.vue +163 -0
- package/components/QDialogFooter.vue +9 -0
- package/components/QDialogHeader.vue +41 -0
- package/components/QDialogProvider.vue +65 -0
- package/components/QDialogTrigger.vue +18 -0
- package/components/QFab.vue +80 -0
- package/components/QFabAction.vue +48 -0
- package/components/QFilePicker.vue +294 -0
- package/components/QFooter.vue +72 -0
- package/components/QGallery.vue +280 -0
- package/components/QGrid.vue +54 -0
- package/components/QHeader.vue +38 -0
- package/components/QIcon.vue +53 -0
- package/components/QImagePicker.vue +248 -0
- package/components/QImagePreview.vue +225 -0
- package/components/QImagePreviewProvider.vue +35 -0
- package/components/QImg.vue +142 -0
- package/components/QInfiniteScroll.vue +109 -0
- package/components/QInnerLoading.vue +67 -0
- package/components/QInput.vue +289 -0
- package/components/QInputOtp.vue +238 -0
- package/components/QInputPassword.vue +67 -0
- package/components/QInputTag.vue +281 -0
- package/components/QIntersection.vue +119 -0
- package/components/QItem.vue +60 -0
- package/components/QItemSection.vue +47 -0
- package/components/QLinearProgress.vue +94 -0
- package/components/QList.vue +39 -0
- package/components/QLoading.vue +77 -0
- package/components/QLoadingProvider.vue +41 -0
- package/components/QMarquee.vue +56 -0
- package/components/QMessageScroller.vue +10 -0
- package/components/QMessageScrollerButton.vue +48 -0
- package/components/QMessageScrollerContent.vue +31 -0
- package/components/QMessageScrollerItem.vue +49 -0
- package/components/QMessageScrollerProvider.vue +198 -0
- package/components/QMessageScrollerViewport.vue +109 -0
- package/components/QNavMenu.vue +75 -0
- package/components/QNavMenuContent.vue +10 -0
- package/components/QNavMenuItem.vue +60 -0
- package/components/QNavMenuTrigger.vue +96 -0
- package/components/QNotifyProvider.vue +33 -0
- package/components/QNotifyToast.vue +60 -0
- package/components/QPage.vue +56 -0
- package/components/QPagination.vue +195 -0
- package/components/QParallax.vue +124 -0
- package/components/QPullToRefresh.vue +138 -0
- package/components/QRadio.vue +99 -0
- package/components/QRating.vue +105 -0
- package/components/QReorder.vue +191 -0
- package/components/QRollingText.vue +225 -0
- package/components/QRow.vue +23 -0
- package/components/QSafeArea.vue +39 -0
- package/components/QScrollArea.vue +118 -0
- package/components/QSelect.vue +698 -0
- package/components/QSeparator.vue +39 -0
- package/components/QSidebar.vue +153 -0
- package/components/QSidebarContent.vue +14 -0
- package/components/QSidebarFooter.vue +14 -0
- package/components/QSidebarHeader.vue +14 -0
- package/components/QSidebarMenu.vue +9 -0
- package/components/QSidebarMenuButton.vue +59 -0
- package/components/QSidebarMenuItem.vue +9 -0
- package/components/QSidebarTrigger.vue +32 -0
- package/components/QSkeleton.vue +95 -0
- package/components/QSlider.vue +205 -0
- package/components/QSpace.vue +10 -0
- package/components/QSpinner.vue +172 -0
- package/components/QSplitter.vue +145 -0
- package/components/QSticky.vue +181 -0
- package/components/QSwipeCell.vue +217 -0
- package/components/QSwiper.vue +102 -0
- package/components/QSwiperSlide.vue +19 -0
- package/components/QSyntax.vue +171 -0
- package/components/QTab.vue +175 -0
- package/components/QTabPanel.vue +50 -0
- package/components/QTabPanels.vue +106 -0
- package/components/QTable.vue +575 -0
- package/components/QTabs.vue +188 -0
- package/components/QText.vue +316 -0
- package/components/QTextCaption.vue +122 -0
- package/components/QToolbar.vue +28 -0
- package/components/QTooltip.vue +313 -0
- package/components/QUploader.vue +473 -0
- package/components/QVideo.vue +289 -0
- package/components/QVirtualScroll.vue +190 -0
- package/components/internal/QDateCalendar.vue +153 -0
- package/index.ts +151 -0
- package/lib/breakpoints.ts +44 -0
- package/lib/colors.ts +24 -0
- package/lib/config.ts +34 -0
- package/lib/countries.ts +261 -0
- package/lib/formula.ts +630 -0
- package/lib/icons.ts +38 -0
- package/lib/loading.ts +75 -0
- package/lib/overlayBack.ts +124 -0
- package/lib/platform.ts +146 -0
- package/lib/primitive.ts +25 -0
- package/lib/q.ts +287 -0
- package/lib/screen.ts +105 -0
- package/lib/search.ts +35 -0
- package/lib/themeVars.ts +22 -0
- package/lib/useComponentProps.ts +54 -0
- package/lib/utils.ts +7 -0
- package/module.ts +89 -0
- package/package.json +41 -0
- package/scripts/generate-exports.ts +56 -0
- package/styles/main.css +7627 -0
- package/tsconfig.json +33 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// QMessageScrollerProvider — possède l'état de scroll (équivalent MessageScrollerProvider shadcn-vue).
|
|
3
|
+
// Contexte : autoScroll, ancrage des tours, visibilité, commandes de scroll.
|
|
4
|
+
import { computed, inject } from "vue"
|
|
5
|
+
import type { InjectionKey, Ref } from "vue"
|
|
6
|
+
|
|
7
|
+
export interface MessageScrollerItemReg {
|
|
8
|
+
id: string
|
|
9
|
+
el: Ref<HTMLElement | null>
|
|
10
|
+
anchor: boolean
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ScrollToOptions {
|
|
14
|
+
align?: "start" | "center" | "end"
|
|
15
|
+
behavior?: ScrollBehavior
|
|
16
|
+
scrollMargin?: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface MessageScrollerContext {
|
|
20
|
+
autoScroll: Readonly<Ref<boolean>>
|
|
21
|
+
defaultPosition: Readonly<Ref<"start" | "end" | "last-anchor">>
|
|
22
|
+
peek: Readonly<Ref<number>>
|
|
23
|
+
margin: Readonly<Ref<number>>
|
|
24
|
+
threshold: Readonly<Ref<number>>
|
|
25
|
+
viewportEl: Ref<HTMLElement | null>
|
|
26
|
+
contentEl: Ref<HTMLElement | null>
|
|
27
|
+
pinned: Readonly<Ref<boolean>>
|
|
28
|
+
scrollable: Readonly<Ref<{ start: boolean; end: boolean }>>
|
|
29
|
+
currentAnchorId: Readonly<Ref<string | null>>
|
|
30
|
+
visibleIds: Readonly<Ref<string[]>>
|
|
31
|
+
items: Readonly<Ref<MessageScrollerItemReg[]>>
|
|
32
|
+
registerItem: (item: MessageScrollerItemReg) => void
|
|
33
|
+
unregisterItem: (id: string) => void
|
|
34
|
+
updateScrollState: () => void
|
|
35
|
+
scrollToEnd: (opts?: { behavior?: ScrollBehavior }) => void
|
|
36
|
+
scrollToStart: (opts?: { behavior?: ScrollBehavior }) => void
|
|
37
|
+
scrollToMessage: (id: string, opts?: ScrollToOptions) => boolean
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const qMessageScrollerKey: InjectionKey<MessageScrollerContext> = Symbol("q-message-scroller")
|
|
41
|
+
|
|
42
|
+
/** Commande le transcript depuis l'extérieur (saut vers un message, début, fin). */
|
|
43
|
+
export function useMessageScroller() {
|
|
44
|
+
const ctx = inject(qMessageScrollerKey, null)
|
|
45
|
+
if (!ctx) throw new Error("useMessageScroller doit être utilisé dans un QMessageScrollerProvider")
|
|
46
|
+
return {
|
|
47
|
+
scrollToMessage: ctx.scrollToMessage,
|
|
48
|
+
scrollToEnd: ctx.scrollToEnd,
|
|
49
|
+
scrollToStart: ctx.scrollToStart,
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Position du lecteur : anchor courant + messages visibles. */
|
|
54
|
+
export function useMessageScrollerVisibility() {
|
|
55
|
+
const ctx = inject(qMessageScrollerKey, null)
|
|
56
|
+
return computed(() => ({
|
|
57
|
+
currentAnchorId: ctx?.currentAnchorId.value ?? null,
|
|
58
|
+
visibleMessageIds: ctx?.visibleIds.value ?? [],
|
|
59
|
+
}))
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Bords encore scrollables (lecture du scroll en JS). */
|
|
63
|
+
export function useMessageScrollerScrollable() {
|
|
64
|
+
const ctx = inject(qMessageScrollerKey, null)
|
|
65
|
+
return computed(() => ctx?.scrollable.value ?? { start: false, end: false })
|
|
66
|
+
}
|
|
67
|
+
</script>
|
|
68
|
+
|
|
69
|
+
<script setup lang="ts">
|
|
70
|
+
// QMessageScrollerProvider — :auto-scroll :default-scroll-position :scroll-previous-item-peek :scroll-margin
|
|
71
|
+
import { nextTick, provide, ref, shallowRef } from "vue"
|
|
72
|
+
|
|
73
|
+
interface Props {
|
|
74
|
+
/** Suit le live edge tant que le lecteur est en bas */
|
|
75
|
+
autoScroll?: boolean
|
|
76
|
+
/** Position d'ouverture : start | end | last-anchor */
|
|
77
|
+
defaultScrollPosition?: "start" | "end" | "last-anchor"
|
|
78
|
+
/** Distance (px) d'un bord avant d'être considéré scrollable */
|
|
79
|
+
scrollEdgeThreshold?: number
|
|
80
|
+
/** Aperçu (px) du message précédent gardé visible lors de l'ancrage */
|
|
81
|
+
scrollPreviousItemPeek?: number
|
|
82
|
+
/** Décalage (px) appliqué en plus lors d'un scrollToMessage */
|
|
83
|
+
scrollMargin?: number
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
87
|
+
autoScroll: false,
|
|
88
|
+
defaultScrollPosition: "end",
|
|
89
|
+
scrollEdgeThreshold: 8,
|
|
90
|
+
scrollPreviousItemPeek: 64,
|
|
91
|
+
scrollMargin: 0,
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
const viewportEl = ref<HTMLElement | null>(null)
|
|
95
|
+
const contentEl = ref<HTMLElement | null>(null)
|
|
96
|
+
const pinned = ref(true)
|
|
97
|
+
const scrollable = ref({ start: false, end: false })
|
|
98
|
+
// shallowRef : ne pas dé-unwrappe les Ref internes des enregistrements
|
|
99
|
+
const items = shallowRef<MessageScrollerItemReg[]>([])
|
|
100
|
+
const currentAnchorId = ref<string | null>(null)
|
|
101
|
+
const visibleIds = ref<string[]>([])
|
|
102
|
+
|
|
103
|
+
const updateScrollState = () => {
|
|
104
|
+
const v = viewportEl.value
|
|
105
|
+
if (!v) return
|
|
106
|
+
const atEnd = v.scrollHeight - v.scrollTop - v.clientHeight <= props.scrollEdgeThreshold
|
|
107
|
+
pinned.value = atEnd
|
|
108
|
+
scrollable.value = {
|
|
109
|
+
start: v.scrollTop > props.scrollEdgeThreshold,
|
|
110
|
+
end: !atEnd,
|
|
111
|
+
}
|
|
112
|
+
// Visibilité (coordonnées relatives au content, positionné)
|
|
113
|
+
const top = v.scrollTop
|
|
114
|
+
const bottom = top + v.clientHeight
|
|
115
|
+
const vis: string[] = []
|
|
116
|
+
let lastAnchor: string | null = null
|
|
117
|
+
for (const it of items.value) {
|
|
118
|
+
const el = it.el.value
|
|
119
|
+
if (!el) continue
|
|
120
|
+
const t = el.offsetTop
|
|
121
|
+
const b = t + el.offsetHeight
|
|
122
|
+
if (b >= top && t <= bottom) vis.push(it.id)
|
|
123
|
+
if (it.anchor && t <= bottom) lastAnchor = it.id
|
|
124
|
+
}
|
|
125
|
+
visibleIds.value = vis
|
|
126
|
+
if (lastAnchor) currentAnchorId.value = lastAnchor
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const scrollToEnd = (opts: { behavior?: ScrollBehavior } = {}) => {
|
|
130
|
+
const v = viewportEl.value
|
|
131
|
+
if (!v) return
|
|
132
|
+
v.scrollTo({ top: v.scrollHeight, behavior: opts.behavior ?? "smooth" })
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const scrollToStart = (opts: { behavior?: ScrollBehavior } = {}) => {
|
|
136
|
+
const v = viewportEl.value
|
|
137
|
+
if (!v) return
|
|
138
|
+
v.scrollTo({ top: 0, behavior: opts.behavior ?? "smooth" })
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const scrollToMessage = (id: string, opts: ScrollToOptions = {}): boolean => {
|
|
142
|
+
const v = viewportEl.value
|
|
143
|
+
const reg = items.value.find((i) => i.id === id)
|
|
144
|
+
const el = reg?.el.value
|
|
145
|
+
if (!v || !el) return false
|
|
146
|
+
const margin = opts.scrollMargin ?? props.scrollMargin
|
|
147
|
+
let top = el.offsetTop - margin
|
|
148
|
+
if (opts.align === "center") top = el.offsetTop - (v.clientHeight - el.offsetHeight) / 2
|
|
149
|
+
else if (opts.align === "end") top = el.offsetTop + el.offsetHeight - v.clientHeight
|
|
150
|
+
v.scrollTo({ top, behavior: opts.behavior ?? "smooth" })
|
|
151
|
+
return true
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Ancrage d'un tour : quand un nouvel item scrollAnchor est ajouté (pas au montage initial),
|
|
155
|
+
// le viewport le place près du haut avec un aperçu du message précédent.
|
|
156
|
+
const registerItem = (item: MessageScrollerItemReg) => {
|
|
157
|
+
const wasEmpty = items.value.length === 0
|
|
158
|
+
items.value = [...items.value, item]
|
|
159
|
+
if (!wasEmpty && item.anchor) {
|
|
160
|
+
nextTick(() => {
|
|
161
|
+
const v = viewportEl.value
|
|
162
|
+
const el = item.el.value
|
|
163
|
+
if (v && el) {
|
|
164
|
+
v.scrollTo({ top: Math.max(0, el.offsetTop - props.scrollPreviousItemPeek), behavior: "auto" })
|
|
165
|
+
}
|
|
166
|
+
})
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const unregisterItem = (id: string) => {
|
|
171
|
+
items.value = items.value.filter((i) => i.id !== id)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
provide<MessageScrollerContext>(qMessageScrollerKey, {
|
|
175
|
+
autoScroll: computed(() => props.autoScroll),
|
|
176
|
+
defaultPosition: computed(() => props.defaultScrollPosition),
|
|
177
|
+
peek: computed(() => props.scrollPreviousItemPeek),
|
|
178
|
+
margin: computed(() => props.scrollMargin),
|
|
179
|
+
threshold: computed(() => props.scrollEdgeThreshold),
|
|
180
|
+
viewportEl,
|
|
181
|
+
contentEl,
|
|
182
|
+
pinned,
|
|
183
|
+
scrollable,
|
|
184
|
+
currentAnchorId,
|
|
185
|
+
visibleIds,
|
|
186
|
+
items,
|
|
187
|
+
registerItem,
|
|
188
|
+
unregisterItem,
|
|
189
|
+
updateScrollState,
|
|
190
|
+
scrollToEnd,
|
|
191
|
+
scrollToStart,
|
|
192
|
+
scrollToMessage,
|
|
193
|
+
})
|
|
194
|
+
</script>
|
|
195
|
+
|
|
196
|
+
<template>
|
|
197
|
+
<slot />
|
|
198
|
+
</template>
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// QMessageScrollerViewport — zone scrollable native (équivalent MessageScrollerViewport).
|
|
3
|
+
// Preserve la position quand l'historique est prépandu (MutationObserver), suit le live edge
|
|
4
|
+
// quand autoScroll est actif, applique la position d'ouverture par défaut.
|
|
5
|
+
import { inject, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue"
|
|
6
|
+
import { qMessageScrollerKey } from "./QMessageScrollerProvider.vue"
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
/** Garde la vue quand des messages sont ajoutés au-dessus */
|
|
10
|
+
preserveScrollOnPrepend?: boolean
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
14
|
+
preserveScrollOnPrepend: true,
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
const ctx = inject(qMessageScrollerKey, null)
|
|
18
|
+
const viewportRef = ref<HTMLElement | null>(null)
|
|
19
|
+
|
|
20
|
+
let resizeObserver: ResizeObserver | null = null
|
|
21
|
+
let mutationObserver: MutationObserver | null = null
|
|
22
|
+
let preservedScrollHeight = 0
|
|
23
|
+
|
|
24
|
+
const onScroll = () => ctx?.updateScrollState()
|
|
25
|
+
|
|
26
|
+
// Prépend (historique chargé au-dessus) : garde le lecteur en place.
|
|
27
|
+
const onMutation = (records: MutationRecord[]) => {
|
|
28
|
+
const content = ctx?.contentEl.value
|
|
29
|
+
const viewport = ctx?.viewportEl.value
|
|
30
|
+
if (!content || !viewport || !ctx) return
|
|
31
|
+
|
|
32
|
+
const prepend = records.some(
|
|
33
|
+
(r) => r.addedNodes.length > 0 && r.previousSibling === null && r.target === content,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
if (prepend) {
|
|
37
|
+
preservedScrollHeight = content.scrollHeight
|
|
38
|
+
requestAnimationFrame(() => {
|
|
39
|
+
if (ctx.pinned.value && ctx.autoScroll.value) {
|
|
40
|
+
ctx.scrollToEnd({ behavior: "auto" })
|
|
41
|
+
}
|
|
42
|
+
else if (props.preserveScrollOnPrepend) {
|
|
43
|
+
const delta = content.scrollHeight - preservedScrollHeight
|
|
44
|
+
if (delta > 0) viewport.scrollTop += delta
|
|
45
|
+
}
|
|
46
|
+
ctx.updateScrollState()
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
else if (ctx.pinned.value && ctx.autoScroll.value) {
|
|
50
|
+
viewport.scrollTop = viewport.scrollHeight
|
|
51
|
+
ctx.updateScrollState()
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Position d'ouverture par défaut (start / end / last-anchor)
|
|
56
|
+
const applyDefaultPosition = async () => {
|
|
57
|
+
await nextTick()
|
|
58
|
+
requestAnimationFrame(() => {
|
|
59
|
+
if (!ctx) return
|
|
60
|
+
const pos = ctx.defaultPosition.value
|
|
61
|
+
if (pos === "end") ctx.scrollToEnd({ behavior: "auto" })
|
|
62
|
+
else if (pos === "start") ctx.scrollToStart({ behavior: "auto" })
|
|
63
|
+
else if (pos === "last-anchor") {
|
|
64
|
+
const anchors = ctx.items.value.filter((i) => i.anchor)
|
|
65
|
+
const last = anchors[anchors.length - 1]
|
|
66
|
+
if (last) ctx.scrollToMessage(last.id, { align: "start", behavior: "auto", scrollMargin: ctx.margin.value })
|
|
67
|
+
}
|
|
68
|
+
ctx.updateScrollState()
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
onMounted(() => {
|
|
73
|
+
if (!ctx) return
|
|
74
|
+
ctx.viewportEl.value = viewportRef.value
|
|
75
|
+
applyDefaultPosition()
|
|
76
|
+
|
|
77
|
+
watch(
|
|
78
|
+
() => ctx.contentEl.value,
|
|
79
|
+
(el) => {
|
|
80
|
+
if (!el) return
|
|
81
|
+
resizeObserver = new ResizeObserver(() => {
|
|
82
|
+
if (ctx && ctx.pinned.value && ctx.autoScroll.value) ctx.scrollToEnd({ behavior: "auto" })
|
|
83
|
+
ctx?.updateScrollState()
|
|
84
|
+
})
|
|
85
|
+
resizeObserver.observe(el)
|
|
86
|
+
mutationObserver = new MutationObserver(onMutation)
|
|
87
|
+
mutationObserver.observe(el, { childList: true })
|
|
88
|
+
},
|
|
89
|
+
)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
onBeforeUnmount(() => {
|
|
93
|
+
resizeObserver?.disconnect()
|
|
94
|
+
mutationObserver?.disconnect()
|
|
95
|
+
})
|
|
96
|
+
</script>
|
|
97
|
+
|
|
98
|
+
<template>
|
|
99
|
+
<div
|
|
100
|
+
ref="viewportRef"
|
|
101
|
+
class="q-msg-scroller__viewport"
|
|
102
|
+
role="region"
|
|
103
|
+
aria-label="Messages"
|
|
104
|
+
tabindex="0"
|
|
105
|
+
@scroll="onScroll"
|
|
106
|
+
>
|
|
107
|
+
<slot />
|
|
108
|
+
</div>
|
|
109
|
+
</template>
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// QNavMenu — barre de navigation type NavigationMenu shadcn-vue, style Quasar.
|
|
3
|
+
// Fournit le contexte (item ouvert, enregistrement des triggers) via provide/inject.
|
|
4
|
+
import type { InjectionKey, Ref } from "vue"
|
|
5
|
+
|
|
6
|
+
export interface NavMenuContext {
|
|
7
|
+
openName: Readonly<Ref<string | null>>
|
|
8
|
+
setOpen: (name: string | null) => void
|
|
9
|
+
registerTrigger: (name: string, el: Ref<HTMLElement | null>) => void
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const qNavMenuKey: InjectionKey<NavMenuContext> = Symbol("q-nav-menu")
|
|
13
|
+
</script>
|
|
14
|
+
|
|
15
|
+
<script setup lang="ts">
|
|
16
|
+
import { onBeforeUnmount, onMounted, provide, ref, shallowRef } from "vue"
|
|
17
|
+
|
|
18
|
+
const openName = ref<string | null>(null)
|
|
19
|
+
const triggers = shallowRef<{ name: string; el: Ref<HTMLElement | null> }[]>([])
|
|
20
|
+
const rootEl = ref<HTMLElement | null>(null)
|
|
21
|
+
|
|
22
|
+
const context: NavMenuContext = {
|
|
23
|
+
openName,
|
|
24
|
+
setOpen: (name) => {
|
|
25
|
+
openName.value = name
|
|
26
|
+
},
|
|
27
|
+
registerTrigger: (name, el) => {
|
|
28
|
+
triggers.value = [...triggers.value, { name, el }]
|
|
29
|
+
},
|
|
30
|
+
}
|
|
31
|
+
provide(qNavMenuKey, context)
|
|
32
|
+
|
|
33
|
+
// Fermeture au clic extérieur
|
|
34
|
+
const onDocMousedown = (e: MouseEvent) => {
|
|
35
|
+
if (rootEl.value && !rootEl.value.contains(e.target as Node)) openName.value = null
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Clavier : flèches/Home/End déplacent le focus, Échap ferme
|
|
39
|
+
const onKeydown = (e: KeyboardEvent) => {
|
|
40
|
+
if (e.key === "Escape") {
|
|
41
|
+
openName.value = null
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
if (e.key !== "ArrowRight" && e.key !== "ArrowLeft" && e.key !== "Home" && e.key !== "End") return
|
|
45
|
+
if (!rootEl.value) return
|
|
46
|
+
const list = Array.from(
|
|
47
|
+
rootEl.value.querySelectorAll(".q-nav-menu__trigger, .q-nav-menu__item"),
|
|
48
|
+
) as HTMLElement[]
|
|
49
|
+
if (list.length === 0) return
|
|
50
|
+
const active = document.activeElement as HTMLElement | null
|
|
51
|
+
const index = active ? list.indexOf(active) : -1
|
|
52
|
+
let target: HTMLElement | undefined
|
|
53
|
+
if (e.key === "ArrowRight") target = list[(index + 1) % list.length]
|
|
54
|
+
else if (e.key === "ArrowLeft") target = list[(index - 1 + list.length) % list.length]
|
|
55
|
+
else if (e.key === "Home") target = list[0]
|
|
56
|
+
else if (e.key === "End") target = list[list.length - 1]
|
|
57
|
+
if (target) {
|
|
58
|
+
e.preventDefault()
|
|
59
|
+
target.focus()
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
onMounted(() => {
|
|
64
|
+
if (typeof document !== "undefined") document.addEventListener("mousedown", onDocMousedown)
|
|
65
|
+
})
|
|
66
|
+
onBeforeUnmount(() => {
|
|
67
|
+
if (typeof document !== "undefined") document.removeEventListener("mousedown", onDocMousedown)
|
|
68
|
+
})
|
|
69
|
+
</script>
|
|
70
|
+
|
|
71
|
+
<template>
|
|
72
|
+
<nav ref="rootEl" class="q-nav-menu" @keydown="onKeydown">
|
|
73
|
+
<slot />
|
|
74
|
+
</nav>
|
|
75
|
+
</template>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// QNavMenuContent — panneau du menu déroulant (équivalent NavigationMenuContent).
|
|
3
|
+
// À placer dans le slot par défaut d'un QNavMenuTrigger.
|
|
4
|
+
</script>
|
|
5
|
+
|
|
6
|
+
<template>
|
|
7
|
+
<div class="q-nav-menu__panel">
|
|
8
|
+
<slot />
|
|
9
|
+
</div>
|
|
10
|
+
</template>
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// QNavMenuItem — lien/bouton de navigation (équivalent NavigationMenuLink).
|
|
3
|
+
import { computed, inject } from "vue"
|
|
4
|
+
import { Icon } from "@iconify/vue"
|
|
5
|
+
import { cn } from "../lib/utils"
|
|
6
|
+
import { qNavMenuKey } from "./QNavMenu.vue"
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
label?: string
|
|
10
|
+
/** Icône Iconify (ex. : "lucide:home") */
|
|
11
|
+
icon?: string
|
|
12
|
+
/** Lien natif (<a>) ; sinon bouton */
|
|
13
|
+
href?: string
|
|
14
|
+
/** État actif (aria-current="page") */
|
|
15
|
+
active?: boolean
|
|
16
|
+
activeClass?: string
|
|
17
|
+
disable?: boolean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
21
|
+
active: false,
|
|
22
|
+
activeClass: "",
|
|
23
|
+
disable: false,
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
const emit = defineEmits<{ click: [event: MouseEvent] }>()
|
|
27
|
+
|
|
28
|
+
const menu = inject(qNavMenuKey, null)
|
|
29
|
+
|
|
30
|
+
const onClick = (e: MouseEvent) => {
|
|
31
|
+
if (props.disable) return
|
|
32
|
+
menu?.setOpen(null) // ferme les dropdowns après navigation
|
|
33
|
+
emit("click", e)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const itemClasses = computed(() =>
|
|
37
|
+
cn(
|
|
38
|
+
"q-nav-menu__item",
|
|
39
|
+
props.active && "q-nav-menu__item--active",
|
|
40
|
+
props.active && props.activeClass,
|
|
41
|
+
props.disable && "q-nav-menu__item--disabled",
|
|
42
|
+
),
|
|
43
|
+
)
|
|
44
|
+
</script>
|
|
45
|
+
|
|
46
|
+
<template>
|
|
47
|
+
<component
|
|
48
|
+
:is="href ? 'a' : 'button'"
|
|
49
|
+
:href="href || undefined"
|
|
50
|
+
:disabled="href ? undefined : disable"
|
|
51
|
+
class="q-nav-menu__item"
|
|
52
|
+
:class="itemClasses"
|
|
53
|
+
:aria-current="active ? 'page' : undefined"
|
|
54
|
+
@click="onClick"
|
|
55
|
+
>
|
|
56
|
+
<Icon :icon="icon" v-if="icon" class="q-nav-menu__item-icon" aria-hidden="true" />
|
|
57
|
+
<span v-if="label" class="q-nav-menu__item-label">{{ label }}</span>
|
|
58
|
+
<slot v-else />
|
|
59
|
+
</component>
|
|
60
|
+
</template>
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// QNavMenuTrigger — bouton de menu avec dropdown ; le contenu du slot par défaut
|
|
3
|
+
// est le panneau déroulant (positionné sous le trigger, ouvert au hover/clic).
|
|
4
|
+
import { computed, inject, onBeforeUnmount, onMounted, ref } from "vue"
|
|
5
|
+
import { Icon } from "@iconify/vue"
|
|
6
|
+
import { icons } from "../lib/icons"
|
|
7
|
+
import { cn } from "../lib/utils"
|
|
8
|
+
import { qNavMenuKey } from "./QNavMenu.vue"
|
|
9
|
+
|
|
10
|
+
let triggerSeq = 0
|
|
11
|
+
|
|
12
|
+
interface Props {
|
|
13
|
+
/** Identifiant (auto-généré si absent) */
|
|
14
|
+
name?: string
|
|
15
|
+
label?: string
|
|
16
|
+
/** Icône Iconify à gauche (ex. : "lucide:star") */
|
|
17
|
+
icon?: string
|
|
18
|
+
disable?: boolean
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
22
|
+
disable: false,
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
const menu = inject(qNavMenuKey, null)
|
|
26
|
+
const resolvedName = props.name ?? `q-nav-${triggerSeq++}`
|
|
27
|
+
const el = ref<HTMLElement | null>(null)
|
|
28
|
+
|
|
29
|
+
onMounted(() => menu?.registerTrigger(resolvedName, el))
|
|
30
|
+
|
|
31
|
+
const isOpen = computed(() => menu?.openName.value === resolvedName)
|
|
32
|
+
|
|
33
|
+
const open = () => {
|
|
34
|
+
if (closeTimer) {
|
|
35
|
+
clearTimeout(closeTimer)
|
|
36
|
+
closeTimer = undefined
|
|
37
|
+
}
|
|
38
|
+
if (!props.disable) menu?.setOpen(resolvedName)
|
|
39
|
+
}
|
|
40
|
+
const close = () => {
|
|
41
|
+
if (isOpen.value) menu?.setOpen(null)
|
|
42
|
+
}
|
|
43
|
+
const toggle = () => {
|
|
44
|
+
if (props.disable) return
|
|
45
|
+
menu?.setOpen(isOpen.value ? null : resolvedName)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Fermeture différée : laisse le temps de traverser l'espace (6px) entre le
|
|
49
|
+
// trigger et le panneau — le mouseenter (re-entrée via le panneau) l'annule.
|
|
50
|
+
let closeTimer: ReturnType<typeof setTimeout> | undefined
|
|
51
|
+
const scheduleClose = () => {
|
|
52
|
+
if (closeTimer) clearTimeout(closeTimer)
|
|
53
|
+
closeTimer = setTimeout(() => {
|
|
54
|
+
closeTimer = undefined
|
|
55
|
+
close()
|
|
56
|
+
}, 150)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
onBeforeUnmount(() => {
|
|
60
|
+
if (closeTimer) clearTimeout(closeTimer)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
const triggerClasses = computed(() =>
|
|
64
|
+
cn("q-nav-menu__trigger", isOpen.value && "q-nav-menu__trigger--open"),
|
|
65
|
+
)
|
|
66
|
+
</script>
|
|
67
|
+
|
|
68
|
+
<template>
|
|
69
|
+
<div ref="el" class="q-nav-menu__trigger-wrap" @mouseenter="open" @mouseleave="scheduleClose">
|
|
70
|
+
<button
|
|
71
|
+
type="button"
|
|
72
|
+
class="q-nav-menu__trigger"
|
|
73
|
+
:class="triggerClasses"
|
|
74
|
+
:disabled="disable"
|
|
75
|
+
:aria-expanded="isOpen ? 'true' : 'false'"
|
|
76
|
+
@click="toggle"
|
|
77
|
+
>
|
|
78
|
+
<Icon :icon="icon" v-if="icon" class="q-nav-menu__trigger-icon" aria-hidden="true" />
|
|
79
|
+
<span v-if="label" class="q-nav-menu__trigger-label">{{ label }}</span>
|
|
80
|
+
<slot name="trigger" />
|
|
81
|
+
<Icon
|
|
82
|
+
:icon="icons.chevronDown"
|
|
83
|
+
class="q-nav-menu__trigger-arrow"
|
|
84
|
+
:class="{ 'q-nav-menu__trigger-arrow--rotated': isOpen }"
|
|
85
|
+
aria-hidden="true"
|
|
86
|
+
/>
|
|
87
|
+
</button>
|
|
88
|
+
<div
|
|
89
|
+
v-if="$slots.default"
|
|
90
|
+
class="q-nav-menu__content"
|
|
91
|
+
:class="{ 'q-nav-menu__content--open': isOpen }"
|
|
92
|
+
>
|
|
93
|
+
<slot />
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
</template>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// QNotifyProvider — notifications $q.notify (vue-sonner / shadcn Sonner).
|
|
3
|
+
// Intégré dans QConfigProvider (rendu automatiquement par le plus externe),
|
|
4
|
+
// mais utilisable aussi en autonome : <q-notify-provider />.
|
|
5
|
+
import { Toaster } from "vue-sonner"
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
/** Position des toasts (défaut : bottom-right) */
|
|
9
|
+
position?: "top-left" | "top-center" | "top-right" | "bottom-left" | "bottom-center" | "bottom-right"
|
|
10
|
+
/** Couleurs riches par type (success/error/…) */
|
|
11
|
+
richColors?: boolean
|
|
12
|
+
/** Bouton fermer sur chaque toast */
|
|
13
|
+
closeButton?: boolean
|
|
14
|
+
/** Thème (défaut : light) */
|
|
15
|
+
theme?: "light" | "dark" | "system"
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
19
|
+
position: "bottom-right",
|
|
20
|
+
richColors: true,
|
|
21
|
+
closeButton: true,
|
|
22
|
+
theme: "light",
|
|
23
|
+
})
|
|
24
|
+
</script>
|
|
25
|
+
|
|
26
|
+
<template>
|
|
27
|
+
<Toaster
|
|
28
|
+
:position="props.position"
|
|
29
|
+
:rich-colors="props.richColors"
|
|
30
|
+
:close-button="props.closeButton"
|
|
31
|
+
:theme="props.theme"
|
|
32
|
+
/>
|
|
33
|
+
</template>
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// QNotifyToast — rendu custom d'une notification $q.notify avec couleur Quasar
|
|
3
|
+
// (utilisé par lib/q.ts via toast.custom de vue-sonner).
|
|
4
|
+
import { computed } from "vue"
|
|
5
|
+
import { Icon } from "@iconify/vue"
|
|
6
|
+
import { colorValue, contrastText } from "../lib/colors"
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
message?: string | number
|
|
10
|
+
caption?: string
|
|
11
|
+
icon?: string
|
|
12
|
+
/** Couleur de fond (token ou hex) — sinon neutre */
|
|
13
|
+
color?: string
|
|
14
|
+
/** Libellé de l'action principale */
|
|
15
|
+
actionLabel?: string
|
|
16
|
+
/** Callback de l'action (fourni par $q.notify) */
|
|
17
|
+
onAction?: () => void
|
|
18
|
+
/** Ferme la notification (fourni par $q.notify) */
|
|
19
|
+
onDismiss?: () => void
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const props = withDefaults(defineProps<Props>(), {})
|
|
23
|
+
|
|
24
|
+
const bg = computed(() => (props.color ? colorValue(props.color) : undefined))
|
|
25
|
+
const fg = computed(() => (props.color ? contrastText(props.color) : "#1d1d1d"))
|
|
26
|
+
</script>
|
|
27
|
+
|
|
28
|
+
<template>
|
|
29
|
+
<div
|
|
30
|
+
class="q-notify"
|
|
31
|
+
:style="bg ? { backgroundColor: bg, color: fg } : undefined"
|
|
32
|
+
role="status"
|
|
33
|
+
>
|
|
34
|
+
<Icon :icon="icon" v-if="icon" class="q-notify__icon" aria-hidden="true" />
|
|
35
|
+
<div class="q-notify__body">
|
|
36
|
+
<div v-if="message" class="q-notify__message">{{ message }}</div>
|
|
37
|
+
<div v-if="caption" class="q-notify__caption" :style="bg ? { color: fg } : undefined">
|
|
38
|
+
{{ caption }}
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
<button
|
|
42
|
+
v-if="actionLabel"
|
|
43
|
+
type="button"
|
|
44
|
+
class="q-notify__action"
|
|
45
|
+
:style="bg ? { color: fg } : undefined"
|
|
46
|
+
@click="onAction?.()"
|
|
47
|
+
>
|
|
48
|
+
{{ actionLabel }}
|
|
49
|
+
</button>
|
|
50
|
+
<button
|
|
51
|
+
v-if="onDismiss"
|
|
52
|
+
type="button"
|
|
53
|
+
class="q-notify__close"
|
|
54
|
+
aria-label="Fermer"
|
|
55
|
+
@click="onDismiss()"
|
|
56
|
+
>
|
|
57
|
+
✕
|
|
58
|
+
</button>
|
|
59
|
+
</div>
|
|
60
|
+
</template>
|