@dnax/ui 0.0.63 → 0.0.64
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/QTabs.vue +70 -2
- package/lib/styleTokens.test.ts +1 -0
- package/lib/tabs.test.ts +35 -0
- package/lib/tabs.ts +38 -0
- package/package.json +1 -1
- package/styles/main.css +60 -0
package/components/QTabs.vue
CHANGED
|
@@ -25,9 +25,12 @@ export const qTabsKey: InjectionKey<QTabContext> = Symbol("q-tabs")
|
|
|
25
25
|
|
|
26
26
|
<script setup lang="ts">
|
|
27
27
|
import { computed, getCurrentInstance, nextTick, onBeforeUnmount, onMounted, provide, ref, shallowRef, watch } from "vue"
|
|
28
|
+
import { Icon } from "@iconify/vue"
|
|
28
29
|
import { cva } from "class-variance-authority"
|
|
29
30
|
import { cn } from "../lib/utils"
|
|
30
31
|
import { colorValue } from "../lib/colors"
|
|
32
|
+
import { icons } from "../lib/icons"
|
|
33
|
+
import { tabArrowState, tabArrowStep } from "../lib/tabs"
|
|
31
34
|
|
|
32
35
|
const tabsVariants = cva("q-tabs", {
|
|
33
36
|
variants: {
|
|
@@ -70,6 +73,12 @@ interface Props {
|
|
|
70
73
|
switchIndicatorPosition?: boolean | "top" | "bottom"
|
|
71
74
|
/** Tabs en colonne (verticales) */
|
|
72
75
|
vertical?: boolean
|
|
76
|
+
/**
|
|
77
|
+
* **Flèches de défilement** quand les tabs débordent (comportement Quasar, défaut `true`) :
|
|
78
|
+
* le contenu devient défilable et deux boutons apparaissent aux extrémités débordantes.
|
|
79
|
+
* `false` → aucun défilement (les tabs débordent, comme avant).
|
|
80
|
+
*/
|
|
81
|
+
showArrows?: boolean
|
|
73
82
|
/** Ne pas s'étendre (usage dans un QToolbar) */
|
|
74
83
|
shrink?: boolean
|
|
75
84
|
/** S'étendre sur l'axe croisé du parent flex */
|
|
@@ -106,6 +115,7 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
106
115
|
noCaps: false,
|
|
107
116
|
switchIndicatorPosition: undefined,
|
|
108
117
|
vertical: false,
|
|
118
|
+
showArrows: true,
|
|
109
119
|
shrink: false,
|
|
110
120
|
stretch: false,
|
|
111
121
|
contentClass: "",
|
|
@@ -148,10 +158,36 @@ const tabRegs = shallowRef<QTabRegistration[]>([])
|
|
|
148
158
|
const tick = ref(0)
|
|
149
159
|
const rootEl = ref<HTMLElement | null>(null)
|
|
150
160
|
|
|
151
|
-
//
|
|
161
|
+
// — Flèches de défilement (`show-arrows`, comportement Quasar) —
|
|
162
|
+
const contentEl = ref<HTMLElement | null>(null)
|
|
163
|
+
const canScrollStart = ref(false)
|
|
164
|
+
const canScrollEnd = ref(false)
|
|
165
|
+
let contentObserver: ResizeObserver | null = null
|
|
166
|
+
|
|
167
|
+
/** Met à jour la visibilité des flèches depuis les mesures de défilement (axe courant). */
|
|
168
|
+
const updateArrows = () => {
|
|
169
|
+
const el = contentEl.value
|
|
170
|
+
if (!el) return
|
|
171
|
+
const state = props.vertical
|
|
172
|
+
? tabArrowState(el.scrollTop, el.clientHeight, el.scrollHeight)
|
|
173
|
+
: tabArrowState(el.scrollLeft, el.clientWidth, el.scrollWidth)
|
|
174
|
+
canScrollStart.value = state.canStart
|
|
175
|
+
canScrollEnd.value = state.canEnd
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Fait défiler d'un cran (≈ 60 % de la zone visible) — fluide. */
|
|
179
|
+
const scrollArrows = (dir: -1 | 1) => {
|
|
180
|
+
const el = contentEl.value
|
|
181
|
+
if (!el) return
|
|
182
|
+
const delta = tabArrowStep(props.vertical ? el.clientHeight : el.clientWidth) * dir
|
|
183
|
+
el.scrollBy(props.vertical ? { top: delta, behavior: "smooth" } : { left: delta, behavior: "smooth" })
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Recalcule la position de l'indicateur (et les flèches) après chaque changement de DOM
|
|
152
187
|
const measure = async () => {
|
|
153
188
|
await nextTick()
|
|
154
189
|
tick.value++
|
|
190
|
+
updateArrows()
|
|
155
191
|
}
|
|
156
192
|
|
|
157
193
|
// collapse-inactive : le label s'étend/retombe via une transition max-width
|
|
@@ -169,10 +205,18 @@ onMounted(() => {
|
|
|
169
205
|
measure()
|
|
170
206
|
if (typeof window !== "undefined") window.addEventListener("resize", measure)
|
|
171
207
|
rootEl.value?.addEventListener("transitionend", onCollapseEnd)
|
|
208
|
+
// Le conteneur peut changer de largeur sans resize fenêtre (sidebar, flex…) : les
|
|
209
|
+
// flèches doivent suivre le débordement réel.
|
|
210
|
+
if (typeof ResizeObserver !== "undefined" && contentEl.value) {
|
|
211
|
+
contentObserver = new ResizeObserver(updateArrows)
|
|
212
|
+
contentObserver.observe(contentEl.value)
|
|
213
|
+
}
|
|
172
214
|
})
|
|
173
215
|
onBeforeUnmount(() => {
|
|
174
216
|
if (typeof window !== "undefined") window.removeEventListener("resize", measure)
|
|
175
217
|
rootEl.value?.removeEventListener("transitionend", onCollapseEnd)
|
|
218
|
+
contentObserver?.disconnect()
|
|
219
|
+
contentObserver = null
|
|
176
220
|
})
|
|
177
221
|
|
|
178
222
|
const context: QTabContext = {
|
|
@@ -240,6 +284,7 @@ const tabsClasses = computed(() =>
|
|
|
240
284
|
props.animated && "q-tabs--animated",
|
|
241
285
|
props.animated && `q-tabs--anim-${props.transition}`,
|
|
242
286
|
props.collapseInactive && "q-tabs--collapse-inactive",
|
|
287
|
+
props.showArrows && "q-tabs--arrows",
|
|
243
288
|
),
|
|
244
289
|
)
|
|
245
290
|
</script>
|
|
@@ -253,9 +298,32 @@ const tabsClasses = computed(() =>
|
|
|
253
298
|
role="tablist"
|
|
254
299
|
:aria-orientation="vertical ? 'vertical' : 'horizontal'"
|
|
255
300
|
>
|
|
256
|
-
<
|
|
301
|
+
<button
|
|
302
|
+
v-if="showArrows && canScrollStart"
|
|
303
|
+
type="button"
|
|
304
|
+
class="q-tabs__arrow q-tabs__arrow--start"
|
|
305
|
+
:aria-label="vertical ? 'Defiler les onglets vers le haut' : 'Defiler les onglets vers la gauche'"
|
|
306
|
+
@click="scrollArrows(-1)"
|
|
307
|
+
>
|
|
308
|
+
<Icon :icon="vertical ? icons.chevronUp : icons.chevronLeft" aria-hidden="true" />
|
|
309
|
+
</button>
|
|
310
|
+
<div
|
|
311
|
+
ref="contentEl"
|
|
312
|
+
class="q-tabs__content"
|
|
313
|
+
:class="contentClass"
|
|
314
|
+
@scroll="updateArrows"
|
|
315
|
+
>
|
|
257
316
|
<slot />
|
|
258
317
|
<div class="q-tabs__indicator" :style="indicatorStyle" aria-hidden="true" />
|
|
259
318
|
</div>
|
|
319
|
+
<button
|
|
320
|
+
v-if="showArrows && canScrollEnd"
|
|
321
|
+
type="button"
|
|
322
|
+
class="q-tabs__arrow q-tabs__arrow--end"
|
|
323
|
+
:aria-label="vertical ? 'Defiler les onglets vers le bas' : 'Defiler les onglets vers la droite'"
|
|
324
|
+
@click="scrollArrows(1)"
|
|
325
|
+
>
|
|
326
|
+
<Icon :icon="vertical ? icons.chevronDown : icons.chevronRight" aria-hidden="true" />
|
|
327
|
+
</button>
|
|
260
328
|
</div>
|
|
261
329
|
</template>
|
package/lib/styleTokens.test.ts
CHANGED
|
@@ -101,6 +101,7 @@ describe("classes de style (composant ↔ CSS)", () => {
|
|
|
101
101
|
".q-bottom-sheet__header-text--with-description .q-bottom-sheet__title",
|
|
102
102
|
],
|
|
103
103
|
["components/QCollapse.vue", "q-collapse--guide", ".q-collapse--guide .q-collapse__inner"],
|
|
104
|
+
["components/QTabs.vue", "q-tabs--arrows", ".q-tabs--arrows .q-tabs__content"],
|
|
104
105
|
]
|
|
105
106
|
|
|
106
107
|
for (const [file, className, selector] of CONTRACTS) {
|
package/lib/tabs.test.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Tests unitaires des flèches de défilement de QTabs (`lib/tabs.ts`).
|
|
2
|
+
import { describe, expect, it } from "bun:test"
|
|
3
|
+
import { tabArrowState, tabArrowStep } from "./tabs"
|
|
4
|
+
|
|
5
|
+
describe("tabArrowState", () => {
|
|
6
|
+
it("pas de débordement → aucune flèche", () => {
|
|
7
|
+
expect(tabArrowState(0, 400, 400)).toEqual({ canStart: false, canEnd: false })
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it("début → seule la flèche de fin", () => {
|
|
11
|
+
expect(tabArrowState(0, 400, 900)).toEqual({ canStart: false, canEnd: true })
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it("milieu → les deux flèches", () => {
|
|
15
|
+
expect(tabArrowState(200, 400, 900)).toEqual({ canStart: true, canEnd: true })
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it("fin → seule la flèche de début", () => {
|
|
19
|
+
expect(tabArrowState(500, 400, 900)).toEqual({ canStart: true, canEnd: false })
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it("tolère l'arrondi des sous-pixels (1 px)", () => {
|
|
23
|
+
// Collé au début malgré un `scrollLeft` de 1 (arrondi)
|
|
24
|
+
expect(tabArrowState(1, 400, 900)).toEqual({ canStart: false, canEnd: true })
|
|
25
|
+
// Collé à la fin malgré un `scrollLeft` à 1 px du max
|
|
26
|
+
expect(tabArrowState(499, 400, 900)).toEqual({ canStart: true, canEnd: false })
|
|
27
|
+
})
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
describe("tabArrowStep", () => {
|
|
31
|
+
it("avance d'environ 60 % de la zone visible, avec un plancher de 80 px", () => {
|
|
32
|
+
expect(tabArrowStep(400)).toBe(240)
|
|
33
|
+
expect(tabArrowStep(100)).toBe(80) // 60 < 80 → plancher
|
|
34
|
+
})
|
|
35
|
+
})
|
package/lib/tabs.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Flèches de défilement de QTabs (`show-arrows`, comportement Quasar) : décide si les boutons
|
|
2
|
+
// de début / fin doivent apparaître, selon la position de défilement et les mesures.
|
|
3
|
+
//
|
|
4
|
+
// Logique **pure**, testable hors navigateur. Les navigateurs arrondissent `scrollLeft` /
|
|
5
|
+
// `scrollTop` (sous-pixels, zoom) : on tolère 1 px pour ne pas faire clignoter une flèche.
|
|
6
|
+
|
|
7
|
+
const EPSILON = 1
|
|
8
|
+
|
|
9
|
+
export interface QTabArrowState {
|
|
10
|
+
/** On peut défiler vers le **début** (gauche / haut) */
|
|
11
|
+
canStart: boolean
|
|
12
|
+
/** On peut défiler vers la **fin** (droite / bas) */
|
|
13
|
+
canEnd: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* État des flèches pour un axe donné.
|
|
18
|
+
*
|
|
19
|
+
* @param position `scrollLeft` / `scrollTop`
|
|
20
|
+
* @param clientSize `clientWidth` / `clientHeight` (partie visible)
|
|
21
|
+
* @param scrollSize `scrollWidth` / `scrollHeight` (contenu total)
|
|
22
|
+
*/
|
|
23
|
+
export function tabArrowState(
|
|
24
|
+
position: number,
|
|
25
|
+
clientSize: number,
|
|
26
|
+
scrollSize: number,
|
|
27
|
+
): QTabArrowState {
|
|
28
|
+
const overflow = scrollSize - clientSize
|
|
29
|
+
return {
|
|
30
|
+
canStart: position > EPSILON,
|
|
31
|
+
canEnd: overflow > EPSILON && position < overflow - EPSILON,
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Déplacement d'un clic sur une flèche : ~60 % de la zone visible (au moins 80 px). */
|
|
36
|
+
export function tabArrowStep(clientSize: number): number {
|
|
37
|
+
return Math.max(80, Math.round(clientSize * 0.6))
|
|
38
|
+
}
|
package/package.json
CHANGED
package/styles/main.css
CHANGED
|
@@ -831,6 +831,62 @@ html.dark body {
|
|
|
831
831
|
align-items: stretch;
|
|
832
832
|
}
|
|
833
833
|
|
|
834
|
+
/* Flèches de défilement (`show-arrows`, comportement Quasar) : le contenu devient
|
|
835
|
+
défilable (barre de défilement masquée, le défilement est piloté par les flèches). */
|
|
836
|
+
.q-tabs--arrows .q-tabs__content {
|
|
837
|
+
overflow-x: auto;
|
|
838
|
+
overflow-y: hidden;
|
|
839
|
+
scrollbar-width: none;
|
|
840
|
+
-ms-overflow-style: none;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
.q-tabs--arrows .q-tabs__content::-webkit-scrollbar {
|
|
844
|
+
display: none;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
.q-tabs--vertical.q-tabs--arrows .q-tabs__content {
|
|
848
|
+
overflow-x: hidden;
|
|
849
|
+
overflow-y: auto;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
.q-tabs__arrow {
|
|
853
|
+
flex: 0 0 auto;
|
|
854
|
+
display: inline-flex;
|
|
855
|
+
align-items: center;
|
|
856
|
+
justify-content: center;
|
|
857
|
+
width: 28px;
|
|
858
|
+
padding: 0;
|
|
859
|
+
border: none;
|
|
860
|
+
border-radius: 0;
|
|
861
|
+
background: transparent;
|
|
862
|
+
color: inherit;
|
|
863
|
+
opacity: 0.6;
|
|
864
|
+
cursor: pointer;
|
|
865
|
+
transition:
|
|
866
|
+
opacity 0.15s ease,
|
|
867
|
+
background-color 0.15s ease;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
.q-tabs__arrow:hover {
|
|
871
|
+
opacity: 1;
|
|
872
|
+
background-color: rgb(0 0 0 / 0.04);
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
/* Thème sombre : le hover noir 4 % est invisible (cf. convention des hovers sombres). */
|
|
876
|
+
.dark .q-tabs__arrow:hover {
|
|
877
|
+
background-color: rgb(255 255 255 / 0.08);
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
.q-tabs__arrow svg {
|
|
881
|
+
width: 16px;
|
|
882
|
+
height: 16px;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
.q-tabs--vertical .q-tabs__arrow {
|
|
886
|
+
width: 100%;
|
|
887
|
+
height: 24px;
|
|
888
|
+
}
|
|
889
|
+
|
|
834
890
|
.q-tabs--dense {
|
|
835
891
|
--q-tabs-h: 32px;
|
|
836
892
|
}
|
|
@@ -841,10 +897,14 @@ html.dark body {
|
|
|
841
897
|
|
|
842
898
|
.q-tabs--center .q-tabs__content {
|
|
843
899
|
justify-content: center;
|
|
900
|
+
/* `safe` : en cas de débordement, retombe sur `flex-start` au lieu de rendre le
|
|
901
|
+
début inaccessible au défilement (bug classique du flex centré + overflow). */
|
|
902
|
+
justify-content: safe center;
|
|
844
903
|
}
|
|
845
904
|
|
|
846
905
|
.q-tabs--right .q-tabs__content {
|
|
847
906
|
justify-content: flex-end;
|
|
907
|
+
justify-content: safe flex-end;
|
|
848
908
|
}
|
|
849
909
|
|
|
850
910
|
.q-tabs--justify .q-tab {
|