@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.
@@ -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
- // Recalcule la position de l'indicateur après chaque changement de DOM
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
- <div class="q-tabs__content" :class="contentClass">
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>
@@ -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) {
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dnax/ui",
3
- "version": "0.0.63",
3
+ "version": "0.0.64",
4
4
  "module": "index.ts",
5
5
  "description": "UI design System",
6
6
  "type": "module",
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 {