@dnax/ui 0.0.32 → 0.0.34
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/components/QBtnActions.vue +294 -0
- package/index.ts +1 -0
- package/lib/icons.ts +2 -0
- package/package.json +1 -1
- package/styles/main.css +114 -0
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// QBtnActions — bouton + menu d'actions intégré (type QBtnDropdown Quasar, mais
|
|
3
|
+
// piloté par données : plus besoin de compose un QMenu/QList séparé).
|
|
4
|
+
// <q-btn-actions label="Actions" :actions="acts" @select-action="onSelect" />
|
|
5
|
+
// Chaque action : { label, value?, icon?, color?, description?, onClick? }.
|
|
6
|
+
// Le panneau est téléporté dans <body> en position fixed : il survit aux
|
|
7
|
+
// contextes d'empilement/overflow (cellules sticky de table, scroll containers).
|
|
8
|
+
|
|
9
|
+
export interface BtnAction {
|
|
10
|
+
/** Libellé affiché dans le menu */
|
|
11
|
+
label: string
|
|
12
|
+
/** Valeur émise par @select-action ; si absente, l'action elle-même est émise */
|
|
13
|
+
value?: any
|
|
14
|
+
/** Icône Iconify à gauche (ex. : "lucide:pencil") */
|
|
15
|
+
icon?: string
|
|
16
|
+
/** Icône Iconify à droite */
|
|
17
|
+
iconRight?: string
|
|
18
|
+
/** Couleur du texte/icône (token — ex. "negative" pour une suppression — ou hex) */
|
|
19
|
+
color?: string
|
|
20
|
+
/** Sous-texte descriptif sous le label */
|
|
21
|
+
description?: string
|
|
22
|
+
/** Ligne de séparation affichée au-dessus de l'action */
|
|
23
|
+
separator?: boolean
|
|
24
|
+
/** Action désactivée */
|
|
25
|
+
disable?: boolean
|
|
26
|
+
/** Rappel local au clic (en plus de l'événement @select-action) */
|
|
27
|
+
onClick?: (action: BtnAction) => void
|
|
28
|
+
}
|
|
29
|
+
</script>
|
|
30
|
+
|
|
31
|
+
<script setup lang="ts">
|
|
32
|
+
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from "vue"
|
|
33
|
+
import { Icon } from "@iconify/vue"
|
|
34
|
+
import { colorValue } from "../lib/colors"
|
|
35
|
+
import { icons } from "../lib/icons"
|
|
36
|
+
import { radiusStyle, useRadius } from "../lib/useComponentProps"
|
|
37
|
+
import type { RadiusProp } from "../lib/useComponentProps"
|
|
38
|
+
import QBtn from "./QBtn.vue"
|
|
39
|
+
|
|
40
|
+
interface Props {
|
|
41
|
+
/** Liste des actions du menu */
|
|
42
|
+
actions?: BtnAction[]
|
|
43
|
+
/** Libellé du bouton déclencheur (sinon icône seule) */
|
|
44
|
+
label?: string
|
|
45
|
+
/** Icône Iconify du déclencheur (défaut "lucide:ellipsis" si pas de label) */
|
|
46
|
+
icon?: string
|
|
47
|
+
/** Masque la flèche déroulante (n'apparaît de toute façon que s'il y a un label) */
|
|
48
|
+
noCaret?: boolean
|
|
49
|
+
/** Icône de la flèche déroulante */
|
|
50
|
+
dropdownIcon?: string
|
|
51
|
+
/** Alignement du menu : bord gauche du bouton ("left") ou bord droit ("right") */
|
|
52
|
+
align?: "left" | "right"
|
|
53
|
+
/** Largeur minimale du menu (ex. "200px") */
|
|
54
|
+
menuWidth?: string
|
|
55
|
+
// — API déclencheur (transmise à QBtn) —
|
|
56
|
+
color?: string
|
|
57
|
+
textColor?: string
|
|
58
|
+
size?: string
|
|
59
|
+
radius?: RadiusProp
|
|
60
|
+
flat?: boolean
|
|
61
|
+
outline?: boolean
|
|
62
|
+
unelevated?: boolean
|
|
63
|
+
dense?: boolean
|
|
64
|
+
round?: boolean
|
|
65
|
+
square?: boolean
|
|
66
|
+
noCaps?: boolean
|
|
67
|
+
stretch?: boolean
|
|
68
|
+
loading?: boolean
|
|
69
|
+
disable?: boolean
|
|
70
|
+
dark?: boolean
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
74
|
+
actions: () => [],
|
|
75
|
+
noCaret: false,
|
|
76
|
+
dropdownIcon: "lucide:chevron-down",
|
|
77
|
+
align: "right",
|
|
78
|
+
menuWidth: "180px",
|
|
79
|
+
size: "md",
|
|
80
|
+
color: "primary",
|
|
81
|
+
flat: false,
|
|
82
|
+
outline: false,
|
|
83
|
+
unelevated: false,
|
|
84
|
+
dense: false,
|
|
85
|
+
round: false,
|
|
86
|
+
square: false,
|
|
87
|
+
noCaps: false,
|
|
88
|
+
stretch: false,
|
|
89
|
+
loading: false,
|
|
90
|
+
disable: false,
|
|
91
|
+
dark: false,
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
const emit = defineEmits<{
|
|
95
|
+
/** Action sélectionnée — payload : `value` de l'action (ou l'action si pas de value) */
|
|
96
|
+
"select-action": [value: any]
|
|
97
|
+
}>()
|
|
98
|
+
|
|
99
|
+
const open = ref(false)
|
|
100
|
+
const rootEl = ref<HTMLElement | null>(null)
|
|
101
|
+
const panelEl = ref<HTMLElement | null>(null)
|
|
102
|
+
/** Position fixed du panneau (téléporté dans <body>) */
|
|
103
|
+
const panelPos = ref({ top: 0, left: 0, transform: "" })
|
|
104
|
+
|
|
105
|
+
const canOpen = computed(() => !props.disable && !props.loading && props.actions.length > 0)
|
|
106
|
+
|
|
107
|
+
// Icône par défaut : « … » si le déclencheur est en icône seule (pas de label)
|
|
108
|
+
const triggerIcon = computed(() => props.icon ?? (props.label ? undefined : icons.ellipsis))
|
|
109
|
+
|
|
110
|
+
// Flèche déroulante : seulement avec un label (bouton icône seule = pas de caret)
|
|
111
|
+
const showCaret = computed(() => !props.noCaret && !!props.label)
|
|
112
|
+
|
|
113
|
+
const triggerClasses = computed(() => [
|
|
114
|
+
"q-btn-actions__trigger",
|
|
115
|
+
open.value && "q-btn-actions__trigger--open",
|
|
116
|
+
])
|
|
117
|
+
|
|
118
|
+
const effectiveRadius = useRadius("QBtnActions", () => props.radius)
|
|
119
|
+
const roundedStyle = computed(() => radiusStyle(effectiveRadius.value))
|
|
120
|
+
|
|
121
|
+
const panelStyle = computed<Record<string, string>>(() => ({
|
|
122
|
+
top: `${panelPos.value.top}px`,
|
|
123
|
+
left: `${panelPos.value.left}px`,
|
|
124
|
+
transform: panelPos.value.transform,
|
|
125
|
+
minWidth: props.menuWidth,
|
|
126
|
+
...(roundedStyle.value ?? {}),
|
|
127
|
+
}))
|
|
128
|
+
|
|
129
|
+
// ═════════ Positionnement (fixed, téléporté) ═════════
|
|
130
|
+
const placePanel = () => {
|
|
131
|
+
const el = rootEl.value
|
|
132
|
+
if (!el || typeof document === "undefined") return
|
|
133
|
+
const rect = el.getBoundingClientRect()
|
|
134
|
+
if (props.align === "left") {
|
|
135
|
+
panelPos.value = { top: rect.bottom + 4, left: rect.left, transform: "none" }
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
// bord droit du panneau aligné sur le bord droit du bouton
|
|
139
|
+
panelPos.value = { top: rect.bottom + 4, left: rect.right, transform: "translateX(-100%)" }
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const enableTracking = () => {
|
|
144
|
+
window.addEventListener("resize", placePanel)
|
|
145
|
+
window.addEventListener("scroll", placePanel, true)
|
|
146
|
+
}
|
|
147
|
+
const disableTracking = () => {
|
|
148
|
+
window.removeEventListener("resize", placePanel)
|
|
149
|
+
window.removeEventListener("scroll", placePanel, true)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ═════════ Ouverture / fermeture ═════════
|
|
153
|
+
const toggle = () => (open.value ? close() : openMenu())
|
|
154
|
+
|
|
155
|
+
const openMenu = async () => {
|
|
156
|
+
if (!canOpen.value) return
|
|
157
|
+
placePanel()
|
|
158
|
+
open.value = true
|
|
159
|
+
enableTracking()
|
|
160
|
+
await nextTick()
|
|
161
|
+
;(panelEl.value?.querySelector(".q-btn-actions__item:not(:disabled)") as HTMLElement | null)?.focus()
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const close = () => {
|
|
165
|
+
if (!open.value) return
|
|
166
|
+
open.value = false
|
|
167
|
+
disableTracking()
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Clic extérieur (bouton ET panneau téléporté) → ferme
|
|
171
|
+
const onDocMousedown = (e: MouseEvent) => {
|
|
172
|
+
if (!open.value) return
|
|
173
|
+
const target = e.target as Node
|
|
174
|
+
if (rootEl.value?.contains(target) || panelEl.value?.contains(target)) return
|
|
175
|
+
close()
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Clavier : flèches/Home/End dans le menu, Échap ferme (focus revient au trigger)
|
|
179
|
+
const onKeydown = (e: KeyboardEvent) => {
|
|
180
|
+
if (!open.value) return
|
|
181
|
+
if (e.key === "Escape") {
|
|
182
|
+
e.preventDefault()
|
|
183
|
+
close()
|
|
184
|
+
;(rootEl.value?.querySelector(".q-btn-actions__trigger") as HTMLElement | null)?.focus()
|
|
185
|
+
return
|
|
186
|
+
}
|
|
187
|
+
if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(e.key)) return
|
|
188
|
+
const items = Array.from(
|
|
189
|
+
panelEl.value?.querySelectorAll(".q-btn-actions__item:not(:disabled)") ?? [],
|
|
190
|
+
) as HTMLElement[]
|
|
191
|
+
if (!items.length) return
|
|
192
|
+
const current = items.indexOf(document.activeElement as HTMLElement)
|
|
193
|
+
let next = current
|
|
194
|
+
if (e.key === "ArrowDown") next = (current + 1) % items.length
|
|
195
|
+
else if (e.key === "ArrowUp") next = (current - 1 + items.length) % items.length
|
|
196
|
+
else if (e.key === "Home") next = 0
|
|
197
|
+
else if (e.key === "End") next = items.length - 1
|
|
198
|
+
e.preventDefault()
|
|
199
|
+
items[next]?.focus()
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const onSelect = (action: BtnAction) => {
|
|
203
|
+
if (action.disable) return
|
|
204
|
+
action.onClick?.(action)
|
|
205
|
+
close()
|
|
206
|
+
emit("select-action", action.value !== undefined ? action.value : action)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Entrée « séparateur seul » ({ separator: true } sans label/icône) : simple trait, pas de bouton cliquable */
|
|
210
|
+
const isSeparatorOnly = (action: BtnAction): boolean =>
|
|
211
|
+
!!action.separator && !action.label && !action.icon && !action.iconRight && !action.description
|
|
212
|
+
|
|
213
|
+
const itemStyle = (action: BtnAction) =>
|
|
214
|
+
action.color ? { color: colorValue(action.color) } : undefined
|
|
215
|
+
|
|
216
|
+
onMounted(() => {
|
|
217
|
+
if (typeof document !== "undefined") document.addEventListener("mousedown", onDocMousedown)
|
|
218
|
+
})
|
|
219
|
+
onBeforeUnmount(() => {
|
|
220
|
+
if (typeof document !== "undefined") document.removeEventListener("mousedown", onDocMousedown)
|
|
221
|
+
disableTracking()
|
|
222
|
+
})
|
|
223
|
+
</script>
|
|
224
|
+
|
|
225
|
+
<template>
|
|
226
|
+
<div ref="rootEl" class="q-btn-actions" :class="dark && 'dark'" @keydown="onKeydown">
|
|
227
|
+
<QBtn
|
|
228
|
+
class="q-btn-actions__trigger"
|
|
229
|
+
:class="triggerClasses"
|
|
230
|
+
:label="label"
|
|
231
|
+
:icon="triggerIcon"
|
|
232
|
+
:color="color"
|
|
233
|
+
:text-color="textColor"
|
|
234
|
+
:size="size"
|
|
235
|
+
:radius="radius"
|
|
236
|
+
:flat="flat"
|
|
237
|
+
:outline="outline"
|
|
238
|
+
:unelevated="unelevated"
|
|
239
|
+
:dense="dense"
|
|
240
|
+
:round="round"
|
|
241
|
+
:square="square"
|
|
242
|
+
:no-caps="noCaps"
|
|
243
|
+
:stretch="stretch"
|
|
244
|
+
:loading="loading"
|
|
245
|
+
:disable="disable"
|
|
246
|
+
:aria-haspopup="'menu'"
|
|
247
|
+
:aria-expanded="open ? 'true' : 'false'"
|
|
248
|
+
@click="toggle"
|
|
249
|
+
>
|
|
250
|
+
<Icon
|
|
251
|
+
v-if="showCaret"
|
|
252
|
+
:icon="dropdownIcon"
|
|
253
|
+
class="q-btn-actions__caret"
|
|
254
|
+
:class="{ 'q-btn-actions__caret--open': open }"
|
|
255
|
+
aria-hidden="true"
|
|
256
|
+
/>
|
|
257
|
+
</QBtn>
|
|
258
|
+
|
|
259
|
+
<Teleport to="body">
|
|
260
|
+
<Transition name="q-popup">
|
|
261
|
+
<div
|
|
262
|
+
v-if="open"
|
|
263
|
+
ref="panelEl"
|
|
264
|
+
class="q-btn-actions__panel"
|
|
265
|
+
role="menu"
|
|
266
|
+
:aria-label="label ?? 'Actions'"
|
|
267
|
+
:style="panelStyle"
|
|
268
|
+
@keydown="onKeydown"
|
|
269
|
+
>
|
|
270
|
+
<template v-for="(action, i) in actions" :key="i">
|
|
271
|
+
<div v-if="action.separator" class="q-btn-actions__separator" role="separator" />
|
|
272
|
+
<button
|
|
273
|
+
v-if="!isSeparatorOnly(action)"
|
|
274
|
+
type="button"
|
|
275
|
+
role="menuitem"
|
|
276
|
+
class="q-btn-actions__item"
|
|
277
|
+
:class="{ 'q-btn-actions__item--disabled': action.disable }"
|
|
278
|
+
:disabled="action.disable"
|
|
279
|
+
:style="itemStyle(action)"
|
|
280
|
+
@click="onSelect(action)"
|
|
281
|
+
>
|
|
282
|
+
<Icon :icon="action.icon" v-if="action.icon" class="q-btn-actions__item-icon" aria-hidden="true" />
|
|
283
|
+
<span class="q-btn-actions__item-text">
|
|
284
|
+
<span class="q-btn-actions__item-label">{{ action.label }}</span>
|
|
285
|
+
<span v-if="action.description" class="q-btn-actions__item-desc">{{ action.description }}</span>
|
|
286
|
+
</span>
|
|
287
|
+
<Icon :icon="action.iconRight" v-if="action.iconRight" class="q-btn-actions__item-icon" aria-hidden="true" />
|
|
288
|
+
</button>
|
|
289
|
+
</template>
|
|
290
|
+
</div>
|
|
291
|
+
</Transition>
|
|
292
|
+
</Teleport>
|
|
293
|
+
</div>
|
|
294
|
+
</template>
|
package/index.ts
CHANGED
|
@@ -17,6 +17,7 @@ export { default as QBottomSheetHeader } from "./components/QBottomSheetHeader.v
|
|
|
17
17
|
export { default as QBottomSheetProvider } from "./components/QBottomSheetProvider.vue"
|
|
18
18
|
export { default as QBottomSheetTrigger } from "./components/QBottomSheetTrigger.vue"
|
|
19
19
|
export { default as QBtn } from "./components/QBtn.vue"
|
|
20
|
+
export { default as QBtnActions } from "./components/QBtnActions.vue"
|
|
20
21
|
export { default as QBtnGroup } from "./components/QBtnGroup.vue"
|
|
21
22
|
export { default as QBubble } from "./components/QBubble.vue"
|
|
22
23
|
export { default as QBubbleContent } from "./components/QBubbleContent.vue"
|
package/lib/icons.ts
CHANGED
|
@@ -14,6 +14,8 @@ export const icons = {
|
|
|
14
14
|
chevronUp: "lucide:chevron-up",
|
|
15
15
|
chevronsLeft: "lucide:chevrons-left",
|
|
16
16
|
chevronsRight: "lucide:chevrons-right",
|
|
17
|
+
ellipsis: "lucide:ellipsis",
|
|
18
|
+
ellipsisVertical: "lucide:ellipsis-vertical",
|
|
17
19
|
copy: "lucide:copy",
|
|
18
20
|
eye: "lucide:eye",
|
|
19
21
|
eyeOff: "lucide:eye-off",
|
package/package.json
CHANGED
package/styles/main.css
CHANGED
|
@@ -306,6 +306,7 @@ html.dark body {
|
|
|
306
306
|
.dark .q-select__popup,
|
|
307
307
|
.dark .q-autocomplete__popup,
|
|
308
308
|
.dark .q-nav-menu__panel,
|
|
309
|
+
.dark .q-btn-actions__panel,
|
|
309
310
|
.dark .q-dialog__content,
|
|
310
311
|
.dark .q-loading__box,
|
|
311
312
|
.dark .q-file-picker__row {
|
|
@@ -313,6 +314,15 @@ html.dark body {
|
|
|
313
314
|
color: var(--foreground);
|
|
314
315
|
}
|
|
315
316
|
|
|
317
|
+
.dark .q-btn-actions__item:hover,
|
|
318
|
+
.dark .q-btn-actions__item:focus-visible {
|
|
319
|
+
background-color: rgb(255 255 255 / 0.08);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
.dark .q-btn-actions__separator {
|
|
323
|
+
background-color: rgb(255 255 255 / 0.14);
|
|
324
|
+
}
|
|
325
|
+
|
|
316
326
|
.dark .q-table {
|
|
317
327
|
color: var(--foreground);
|
|
318
328
|
}
|
|
@@ -2434,6 +2444,110 @@ html.dark body {
|
|
|
2434
2444
|
flex: 1 1 auto;
|
|
2435
2445
|
}
|
|
2436
2446
|
|
|
2447
|
+
/* ===== QBtnActions ===== */
|
|
2448
|
+
.q-btn-actions {
|
|
2449
|
+
position: relative;
|
|
2450
|
+
display: inline-flex;
|
|
2451
|
+
vertical-align: middle;
|
|
2452
|
+
}
|
|
2453
|
+
|
|
2454
|
+
.q-btn-actions__caret {
|
|
2455
|
+
width: 1em;
|
|
2456
|
+
height: 1em;
|
|
2457
|
+
flex-shrink: 0;
|
|
2458
|
+
transition: transform 0.2s ease;
|
|
2459
|
+
}
|
|
2460
|
+
|
|
2461
|
+
.q-btn-actions__caret--open {
|
|
2462
|
+
transform: rotate(180deg);
|
|
2463
|
+
}
|
|
2464
|
+
|
|
2465
|
+
.q-btn-actions__panel {
|
|
2466
|
+
position: fixed;
|
|
2467
|
+
z-index: 3000;
|
|
2468
|
+
padding: 4px;
|
|
2469
|
+
background-color: #fff;
|
|
2470
|
+
border: 1px solid rgb(0 0 0 / 0.12);
|
|
2471
|
+
border-radius: var(--q-radius, 6px);
|
|
2472
|
+
box-shadow: 0 4px 16px rgb(0 0 0 / 0.15);
|
|
2473
|
+
}
|
|
2474
|
+
|
|
2475
|
+
.q-btn-actions__item {
|
|
2476
|
+
display: flex;
|
|
2477
|
+
align-items: center;
|
|
2478
|
+
gap: 8px;
|
|
2479
|
+
width: 100%;
|
|
2480
|
+
padding: 7px 8px;
|
|
2481
|
+
border: none;
|
|
2482
|
+
border-radius: 4px;
|
|
2483
|
+
background: transparent;
|
|
2484
|
+
color: inherit;
|
|
2485
|
+
font: inherit;
|
|
2486
|
+
font-size: 14px;
|
|
2487
|
+
line-height: 1.35;
|
|
2488
|
+
text-align: left;
|
|
2489
|
+
cursor: pointer;
|
|
2490
|
+
user-select: none;
|
|
2491
|
+
}
|
|
2492
|
+
|
|
2493
|
+
.q-btn-actions__item:hover,
|
|
2494
|
+
.q-btn-actions__item:focus-visible {
|
|
2495
|
+
background-color: rgb(0 0 0 / 0.06);
|
|
2496
|
+
outline: none;
|
|
2497
|
+
}
|
|
2498
|
+
|
|
2499
|
+
.q-btn-actions__item--disabled {
|
|
2500
|
+
opacity: 0.5;
|
|
2501
|
+
pointer-events: none;
|
|
2502
|
+
}
|
|
2503
|
+
|
|
2504
|
+
.q-btn-actions__item-icon {
|
|
2505
|
+
width: 16px;
|
|
2506
|
+
height: 16px;
|
|
2507
|
+
flex-shrink: 0;
|
|
2508
|
+
}
|
|
2509
|
+
|
|
2510
|
+
.q-btn-actions__item-text {
|
|
2511
|
+
flex: 1;
|
|
2512
|
+
min-width: 0;
|
|
2513
|
+
display: flex;
|
|
2514
|
+
flex-direction: column;
|
|
2515
|
+
}
|
|
2516
|
+
|
|
2517
|
+
.q-btn-actions__item-label {
|
|
2518
|
+
overflow: hidden;
|
|
2519
|
+
text-overflow: ellipsis;
|
|
2520
|
+
white-space: nowrap;
|
|
2521
|
+
}
|
|
2522
|
+
|
|
2523
|
+
.q-btn-actions__item-desc {
|
|
2524
|
+
font-size: 12px;
|
|
2525
|
+
opacity: 0.6;
|
|
2526
|
+
}
|
|
2527
|
+
|
|
2528
|
+
.q-btn-actions__separator {
|
|
2529
|
+
height: 1px;
|
|
2530
|
+
margin: 4px 6px;
|
|
2531
|
+
background-color: rgb(0 0 0 / 0.1);
|
|
2532
|
+
}
|
|
2533
|
+
|
|
2534
|
+
.q-popup-enter-active {
|
|
2535
|
+
animation: q-popup-fade-in 0.12s ease;
|
|
2536
|
+
}
|
|
2537
|
+
|
|
2538
|
+
.q-popup-leave-active {
|
|
2539
|
+
animation: q-popup-fade-in 0.1s ease reverse;
|
|
2540
|
+
}
|
|
2541
|
+
|
|
2542
|
+
@keyframes q-popup-fade-in {
|
|
2543
|
+
from {
|
|
2544
|
+
opacity: 0;
|
|
2545
|
+
}
|
|
2546
|
+
to {
|
|
2547
|
+
opacity: 1;
|
|
2548
|
+
}
|
|
2549
|
+
}
|
|
2550
|
+
|
|
2437
2551
|
/* ===== QImg ===== */
|
|
2438
2552
|
.q-img {
|
|
2439
2553
|
position: relative;
|