@dnax/ui 0.0.48 → 0.0.50

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 (48) hide show
  1. package/components/QBottomSheet.vue +172 -6
  2. package/components/QChart.vue +1 -0
  3. package/components/QDatePicker.vue +1 -1
  4. package/components/QImagePicker.vue +28 -1
  5. package/components/QInputChat.vue +859 -0
  6. package/components/QInputCurrency.vue +319 -0
  7. package/components/QItemLabel.vue +99 -0
  8. package/components/QMap.vue +410 -0
  9. package/components/QNumericKeyboard.vue +304 -0
  10. package/components/QPage.vue +26 -6
  11. package/components/QPageContainer.vue +32 -0
  12. package/components/QPopupProxy.vue +378 -0
  13. package/components/QQrcode.vue +132 -0
  14. package/components/QSelect.vue +12 -0
  15. package/components/QSpreadsheet.vue +395 -69
  16. package/components/QTime.vue +845 -0
  17. package/components/QTimeline.vue +111 -0
  18. package/components/QTimelineEntry.vue +271 -0
  19. package/index.ts +11 -1
  20. package/lib/bottomSheet.test.ts +121 -0
  21. package/lib/bottomSheet.ts +115 -0
  22. package/lib/color.ts +13 -0
  23. package/lib/currency.test.ts +193 -0
  24. package/lib/currency.ts +301 -0
  25. package/lib/fixedLayout.ts +12 -11
  26. package/lib/icons.ts +3 -20
  27. package/lib/map.test.ts +365 -0
  28. package/lib/map.ts +438 -0
  29. package/lib/mapEngine.ts +158 -0
  30. package/lib/mapLeaflet.ts +142 -0
  31. package/lib/mapMaplibre.ts +106 -0
  32. package/lib/mapMaptiler.ts +93 -0
  33. package/lib/numericKeyboard.test.ts +179 -0
  34. package/lib/numericKeyboard.ts +166 -0
  35. package/lib/pagePadding.test.ts +37 -0
  36. package/lib/pagePadding.ts +32 -0
  37. package/lib/qrcode.test.ts +103 -0
  38. package/lib/qrcode.ts +125 -0
  39. package/lib/spreadsheetChanges.test.ts +230 -0
  40. package/lib/spreadsheetChanges.ts +331 -0
  41. package/package.json +5 -17
  42. package/shims.d.ts +19 -0
  43. package/styles/main.css +689 -884
  44. package/components/QTiptap.vue +0 -996
  45. package/components/internal/QTiptapTableView.vue +0 -123
  46. package/components/internal/QTiptapTaskItemView.vue +0 -49
  47. package/lib/tiptap-table.ts +0 -19
  48. package/lib/tiptap-task-list.ts +0 -16
@@ -0,0 +1,111 @@
1
+ <script lang="ts">
2
+ // QTimeline — conteneur d'une frise chronologique verticale (API Quasar).
3
+ // <q-timeline color="primary" layout="comfortable">
4
+ // <q-timeline-entry heading title="2024" />
5
+ // <q-timeline-entry title="Shipped" icon="lucide:check" />
6
+ // </q-timeline>
7
+ //
8
+ // Le conteneur dessine le rail (ligne de 1px) et transmet la couleur et
9
+ // l'espacement aux entrées, à la fois par `provide`/`inject` (ci-dessous) et par
10
+ // des variables CSS héritées (`--q-timeline-color`, `--q-timeline-gap`…).
11
+ import type { ComputedRef, InjectionKey } from "vue"
12
+
13
+ /** Espacement vertical de la frise */
14
+ export type TimelineLayout = "dense" | "comfortable" | "loose"
15
+
16
+ /** Contexte hérité par les entrées (voir `qTimelineKey`). */
17
+ export interface TimelineContext {
18
+ /** Couleur du rail et des pastilles (token ou hex) */
19
+ color: string
20
+ /** Espacement vertical des entrées */
21
+ layout: TimelineLayout
22
+ }
23
+
24
+ /** Clé locale d'injection : couleur + layout du conteneur vers les entrées. */
25
+ export const qTimelineKey: InjectionKey<ComputedRef<TimelineContext>> = Symbol("q-timeline")
26
+ </script>
27
+
28
+ <script setup lang="ts">
29
+ import { computed, provide } from "vue"
30
+ import { cn } from "../lib/utils"
31
+ import { colorValue, foregroundFor } from "../lib/colors"
32
+
33
+ interface Props {
34
+ /** Couleur du rail et des pastilles par défaut (token ou hex) */
35
+ color?: string
36
+ /** Thème sombre */
37
+ dark?: boolean
38
+ /** Élément rendu (`ul` par défaut) */
39
+ tag?: string
40
+ /** Espacement vertical : dense | comfortable (défaut) | loose */
41
+ layout?: TimelineLayout
42
+ }
43
+
44
+ const props = withDefaults(defineProps<Props>(), {
45
+ color: "primary",
46
+ dark: false,
47
+ tag: "ul",
48
+ layout: "comfortable",
49
+ })
50
+
51
+ const timelineClasses = computed(() =>
52
+ cn("q-timeline", `q-timeline--${props.layout}`, props.dark && "q-timeline--dark"),
53
+ )
54
+
55
+ // Variables CSS posées sur la racine : le rail les lit, les entrées les héritent.
56
+ const timelineStyle = computed<Record<string, string>>(() => ({
57
+ "--q-timeline-color": colorValue(props.color),
58
+ "--q-timeline-color-foreground": foregroundFor(props.color),
59
+ ...(props.dark
60
+ ? {
61
+ "--q-timeline-fg": "#e6edf3",
62
+ "--q-timeline-muted": "rgb(255 255 255 / 0.6)",
63
+ "--q-timeline-surface": "#0f1115",
64
+ }
65
+ : {}),
66
+ }))
67
+
68
+ provide(qTimelineKey, computed<TimelineContext>(() => ({ color: props.color, layout: props.layout })))
69
+ </script>
70
+
71
+ <template>
72
+ <component
73
+ :is="tag"
74
+ class="q-timeline"
75
+ :class="timelineClasses"
76
+ :style="timelineStyle"
77
+ role="list"
78
+ v-bind="$attrs"
79
+ >
80
+ <slot />
81
+ </component>
82
+ </template>
83
+
84
+ <style scoped>
85
+ .q-timeline {
86
+ position: relative;
87
+ width: 100%;
88
+ margin: 0;
89
+ padding: 0;
90
+ list-style: none;
91
+ color: var(--q-timeline-fg, var(--foreground, #1d1d1d));
92
+ }
93
+
94
+ /* Rail vertical : une ligne de 1px centrée qui traverse toute la frise.
95
+ La couleur est héritée du conteneur, surchargeable par entrée. */
96
+ .q-timeline::before {
97
+ content: "";
98
+ position: absolute;
99
+ top: 0;
100
+ bottom: 0;
101
+ left: 50%;
102
+ width: 1px;
103
+ transform: translateX(-50%);
104
+ background-color: var(--q-timeline-color, var(--primary));
105
+ }
106
+
107
+ /* Thème sombre forcé par la prop `dark` (les variables sont posées inline). */
108
+ .q-timeline--dark {
109
+ color: var(--q-timeline-fg, #e6edf3);
110
+ }
111
+ </style>
@@ -0,0 +1,271 @@
1
+ <script setup lang="ts">
2
+ // QTimelineEntry — une entrée de <q-timeline> (API Quasar).
3
+ // <q-timeline-entry heading title="2024" />
4
+ // <q-timeline-entry title="Shipped" subtitle="2 days ago" icon="lucide:check" color="positive" />
5
+ //
6
+ // `side` place l'entrée à gauche ou à droite du rail. **Sans `side`, l'alternance
7
+ // est automatique** : la classe `q-timeline__entry--auto` s'appuie sur
8
+ // `:nth-child(odd|even)` de la racine du parent (impair → gauche, pair → droite) —
9
+ // un `heading` compte donc dans l'alternance, comme dans Quasar.
10
+ import { computed, inject } from "vue"
11
+ import { Icon } from "@iconify/vue"
12
+ import { cn } from "../lib/utils"
13
+ import { colorValue, foregroundFor } from "../lib/colors"
14
+ import { qTimelineKey, type TimelineLayout } from "./QTimeline.vue"
15
+
16
+ interface Props {
17
+ /** Entrée « titre de période » : centrée, sans pastille ni côté */
18
+ heading?: boolean
19
+ /** Élément rendu (`li` par défaut) */
20
+ tag?: string
21
+ /** Côté de l'entrée : left | right (défaut : alternance automatique) */
22
+ side?: "left" | "right"
23
+ /** Icône Iconify dans la pastille (ex. "lucide:check") */
24
+ icon?: string
25
+ /** URL d'image dans la pastille (prioritaire sur l'icône) */
26
+ avatar?: string
27
+ /** Titre de l'entrée */
28
+ title?: string
29
+ /** Sous-titre (date, lieu…) */
30
+ subtitle?: string
31
+ /** Couleur de la pastille (token ou hex) — héritée du conteneur sinon */
32
+ color?: string
33
+ /** Thème sombre */
34
+ dark?: boolean
35
+ }
36
+
37
+ const props = withDefaults(defineProps<Props>(), {
38
+ heading: false,
39
+ tag: "li",
40
+ icon: "",
41
+ avatar: "",
42
+ title: "",
43
+ subtitle: "",
44
+ color: "",
45
+ dark: false,
46
+ })
47
+
48
+ // Contexte du conteneur, avec repli si l'entrée est utilisée hors <q-timeline>.
49
+ const timeline = inject(qTimelineKey, undefined)
50
+ const parentColor = computed(() => timeline?.value.color ?? "primary")
51
+ const layout = computed<TimelineLayout>(() => timeline?.value.layout ?? "comfortable")
52
+
53
+ /** Espacement vertical selon le layout hérité du conteneur. */
54
+ const LAYOUT_GAP: Record<TimelineLayout, string> = {
55
+ dense: "12px",
56
+ comfortable: "26px",
57
+ loose: "42px",
58
+ }
59
+
60
+ const entryClasses = computed(() =>
61
+ cn(
62
+ "q-timeline__entry",
63
+ // côté explicite, sinon alternance automatique (nth-child) portée par `--auto`
64
+ props.side ? `q-timeline__entry--${props.side}` : "q-timeline__entry--auto",
65
+ props.dark && "q-timeline__entry--dark",
66
+ ),
67
+ )
68
+
69
+ const entryStyle = computed<Record<string, string>>(() => {
70
+ const style: Record<string, string> = { "--q-timeline-gap": LAYOUT_GAP[layout.value] }
71
+
72
+ // Couleur propre à l'entrée → surcharge la couleur héritée du conteneur ;
73
+ // sinon on déduit seulement la couleur de texte de la pastille.
74
+ if (props.color) {
75
+ style["--q-timeline-color"] = colorValue(props.color)
76
+ style["--q-timeline-color-foreground"] = foregroundFor(props.color)
77
+ }
78
+ else {
79
+ style["--q-timeline-color-foreground"] = foregroundFor(parentColor.value)
80
+ }
81
+
82
+ if (props.dark) {
83
+ style["--q-timeline-fg"] = "#e6edf3"
84
+ style["--q-timeline-muted"] = "rgb(255 255 255 / 0.6)"
85
+ style["--q-timeline-surface"] = "#0f1115"
86
+ }
87
+
88
+ return style
89
+ })
90
+ </script>
91
+
92
+ <template>
93
+ <!-- Titre de période : centré, sans pastille -->
94
+ <component
95
+ v-if="heading"
96
+ :is="tag"
97
+ class="q-timeline__heading"
98
+ :style="entryStyle"
99
+ role="listitem"
100
+ v-bind="$attrs"
101
+ >
102
+ <span class="q-timeline__heading-title">
103
+ <slot name="title">
104
+ <slot>{{ title }}</slot>
105
+ </slot>
106
+ </span>
107
+ </component>
108
+
109
+ <!-- Entrée standard : pastille posée sur le rail + contenu -->
110
+ <component
111
+ v-else
112
+ :is="tag"
113
+ class="q-timeline__entry"
114
+ :class="entryClasses"
115
+ :style="entryStyle"
116
+ role="listitem"
117
+ v-bind="$attrs"
118
+ >
119
+ <span class="q-timeline__dot">
120
+ <slot name="icon">
121
+ <img v-if="avatar" class="q-timeline__avatar" :src="avatar" alt="" />
122
+ <Icon v-else-if="icon" :icon="icon" class="q-timeline__icon" aria-hidden="true" />
123
+ </slot>
124
+ </span>
125
+
126
+ <div class="q-timeline__content">
127
+ <div v-if="$slots.title || title" class="q-timeline__title">
128
+ <slot name="title">{{ title }}</slot>
129
+ </div>
130
+ <div v-if="$slots.subtitle || subtitle" class="q-timeline__subtitle">
131
+ <slot name="subtitle">{{ subtitle }}</slot>
132
+ </div>
133
+ <div v-if="$slots.default" class="q-timeline__body">
134
+ <slot />
135
+ </div>
136
+ </div>
137
+ </component>
138
+ </template>
139
+
140
+ <style scoped>
141
+ /* ─── Tronc commun : entrée et titre de période ─────────────────────────── */
142
+ .q-timeline__entry,
143
+ .q-timeline__heading {
144
+ position: relative;
145
+ padding-bottom: var(--q-timeline-gap, 26px);
146
+ list-style: none;
147
+ }
148
+
149
+ /* ─── Entrée standard : une demi-colonne autour du rail centré ──────────── */
150
+ .q-timeline__entry {
151
+ box-sizing: border-box;
152
+ width: 50%;
153
+ }
154
+
155
+ /* Côté gauche (impair par défaut) : contenu poussé vers le rail, à droite */
156
+ .q-timeline__entry--left,
157
+ .q-timeline__entry--auto:nth-child(odd) {
158
+ margin-right: auto;
159
+ padding-right: 30px;
160
+ text-align: right;
161
+ }
162
+
163
+ /* Côté droit (pair par défaut) : contenu poussé vers le rail, à gauche */
164
+ .q-timeline__entry--right,
165
+ .q-timeline__entry--auto:nth-child(even) {
166
+ margin-left: auto;
167
+ padding-left: 30px;
168
+ text-align: left;
169
+ }
170
+
171
+ /* ─── Pastille posée sur le rail ────────────────────────────────────────── */
172
+ .q-timeline__dot {
173
+ position: absolute;
174
+ top: 0;
175
+ z-index: 1;
176
+ display: inline-flex;
177
+ align-items: center;
178
+ justify-content: center;
179
+ width: 20px;
180
+ height: 20px;
181
+ overflow: hidden;
182
+ border-radius: 50%;
183
+ background-color: var(--q-timeline-color, var(--primary));
184
+ color: var(--q-timeline-color-foreground, var(--primary-foreground, #fff));
185
+ /* anneau qui détache la pastille du rail */
186
+ box-shadow: 0 0 0 3px var(--q-timeline-surface, var(--background, #fff));
187
+ }
188
+
189
+ .q-timeline__entry--left .q-timeline__dot,
190
+ .q-timeline__entry--auto:nth-child(odd) .q-timeline__dot {
191
+ right: 0;
192
+ transform: translateX(50%);
193
+ }
194
+
195
+ .q-timeline__entry--right .q-timeline__dot,
196
+ .q-timeline__entry--auto:nth-child(even) .q-timeline__dot {
197
+ left: 0;
198
+ transform: translateX(-50%);
199
+ }
200
+
201
+ .q-timeline__icon {
202
+ width: 12px;
203
+ height: 12px;
204
+ }
205
+
206
+ .q-timeline__avatar {
207
+ width: 100%;
208
+ height: 100%;
209
+ object-fit: cover;
210
+ }
211
+
212
+ /* ─── Contenu de l'entrée ───────────────────────────────────────────────── */
213
+ .q-timeline__content {
214
+ display: inline-block;
215
+ max-width: 100%;
216
+ text-align: left;
217
+ }
218
+
219
+ .q-timeline__title {
220
+ font-size: 15px;
221
+ font-weight: 600;
222
+ line-height: 1.35;
223
+ color: var(--q-timeline-fg, var(--foreground, #1d1d1d));
224
+ }
225
+
226
+ .q-timeline__subtitle {
227
+ margin-top: 2px;
228
+ font-size: 12.5px;
229
+ color: var(--q-timeline-muted, var(--muted-foreground, rgb(0 0 0 / 0.55)));
230
+ }
231
+
232
+ .q-timeline__body {
233
+ margin-top: 6px;
234
+ font-size: 14px;
235
+ line-height: 1.55;
236
+ color: var(--q-timeline-muted, var(--muted-foreground, rgb(0 0 0 / 0.68)));
237
+ }
238
+
239
+ /* ─── Titre de période : centré, sans pastille ──────────────────────────── */
240
+ .q-timeline__heading {
241
+ z-index: 1;
242
+ text-align: center;
243
+ }
244
+
245
+ .q-timeline__heading-title {
246
+ display: inline-block;
247
+ padding: 3px 14px;
248
+ font-size: 12px;
249
+ font-weight: 700;
250
+ letter-spacing: 0.06em;
251
+ text-transform: uppercase;
252
+ color: var(--q-timeline-fg, var(--foreground, #1d1d1d));
253
+ background-color: var(--q-timeline-surface, var(--background, #fff));
254
+ border-radius: 999px;
255
+ }
256
+
257
+ /* ─── Thème sombre ──────────────────────────────────────────────────────── */
258
+ /* La prop `dark` est traitée par les variables inline (cf. entryStyle).
259
+ Mode sombre ambiant : repli quand les jetons du design system sont absents. */
260
+ .dark .q-timeline__title,
261
+ .dark .q-timeline__heading-title {
262
+ color: var(--q-timeline-fg, #e6edf3);
263
+ }
264
+ .dark .q-timeline__subtitle,
265
+ .dark .q-timeline__body {
266
+ color: var(--q-timeline-muted, rgb(255 255 255 / 0.6));
267
+ }
268
+ .dark .q-timeline__heading-title {
269
+ background-color: var(--q-timeline-surface, #0f1115);
270
+ }
271
+ </style>
package/index.ts CHANGED
@@ -67,18 +67,22 @@ export { default as QImg } from "./components/QImg.vue"
67
67
  export { default as QInfiniteScroll } from "./components/QInfiniteScroll.vue"
68
68
  export { default as QInnerLoading } from "./components/QInnerLoading.vue"
69
69
  export { default as QInput } from "./components/QInput.vue"
70
+ export { default as QInputChat } from "./components/QInputChat.vue"
71
+ export { default as QInputCurrency } from "./components/QInputCurrency.vue"
70
72
  export { default as QInputOtp } from "./components/QInputOtp.vue"
71
73
  export { default as QInputPassword } from "./components/QInputPassword.vue"
72
74
  export { default as QInputTag } from "./components/QInputTag.vue"
73
75
  export { default as QInteract } from "./components/QInteract.vue"
74
76
  export { default as QIntersection } from "./components/QIntersection.vue"
75
77
  export { default as QItem } from "./components/QItem.vue"
78
+ export { default as QItemLabel } from "./components/QItemLabel.vue"
76
79
  export { default as QItemSection } from "./components/QItemSection.vue"
77
80
  export { default as QLayout } from "./components/QLayout.vue"
78
81
  export { default as QLinearProgress } from "./components/QLinearProgress.vue"
79
82
  export { default as QList } from "./components/QList.vue"
80
83
  export { default as QLoading } from "./components/QLoading.vue"
81
84
  export { default as QLoadingProvider } from "./components/QLoadingProvider.vue"
85
+ export { default as QMap } from "./components/QMap.vue"
82
86
  export { default as QMarquee } from "./components/QMarquee.vue"
83
87
  export { default as QMessageScroller } from "./components/QMessageScroller.vue"
84
88
  export { default as QMessageScrollerButton } from "./components/QMessageScrollerButton.vue"
@@ -92,10 +96,14 @@ export { default as QNavMenuItem } from "./components/QNavMenuItem.vue"
92
96
  export { default as QNavMenuTrigger } from "./components/QNavMenuTrigger.vue"
93
97
  export { default as QNotifyProvider } from "./components/QNotifyProvider.vue"
94
98
  export { default as QNotifyToast } from "./components/QNotifyToast.vue"
99
+ export { default as QNumericKeyboard } from "./components/QNumericKeyboard.vue"
95
100
  export { default as QPage } from "./components/QPage.vue"
101
+ export { default as QPageContainer } from "./components/QPageContainer.vue"
96
102
  export { default as QPagination } from "./components/QPagination.vue"
97
103
  export { default as QParallax } from "./components/QParallax.vue"
104
+ export { default as QPopupProxy } from "./components/QPopupProxy.vue"
98
105
  export { default as QPullToRefresh } from "./components/QPullToRefresh.vue"
106
+ export { default as QQrcode } from "./components/QQrcode.vue"
99
107
  export { default as QRadio } from "./components/QRadio.vue"
100
108
  export { default as QRating } from "./components/QRating.vue"
101
109
  export { default as QReorder } from "./components/QReorder.vue"
@@ -132,7 +140,9 @@ export { default as QTable } from "./components/QTable.vue"
132
140
  export { default as QTabs } from "./components/QTabs.vue"
133
141
  export { default as QText } from "./components/QText.vue"
134
142
  export { default as QTextCaption } from "./components/QTextCaption.vue"
135
- export { default as QTiptap } from "./components/QTiptap.vue"
143
+ export { default as QTime } from "./components/QTime.vue"
144
+ export { default as QTimeline } from "./components/QTimeline.vue"
145
+ export { default as QTimelineEntry } from "./components/QTimelineEntry.vue"
136
146
  export { default as QToolbar } from "./components/QToolbar.vue"
137
147
  export { default as QTooltip } from "./components/QTooltip.vue"
138
148
  export { default as QUploader } from "./components/QUploader.vue"
@@ -0,0 +1,121 @@
1
+ // Tests unitaires des points d'ancrage (`breakpoints`) du bottom sheet.
2
+ import { describe, expect, it } from "bun:test"
3
+ import {
4
+ clampRatio,
5
+ nearestBreakpoint,
6
+ normalizeBreakpoints,
7
+ ratioFromDrag,
8
+ releaseBreakpoint,
9
+ stepBreakpoint,
10
+ } from "./bottomSheet"
11
+
12
+ describe("normalizeBreakpoints", () => {
13
+ it("trie et dédoublonne les fractions de ]0, 1]", () => {
14
+ expect(normalizeBreakpoints([0.5, 0.25, 0.75, 0.25])).toEqual([0.25, 0.5, 0.75])
15
+ expect(normalizeBreakpoints([1, 0.5])).toEqual([0.5, 1])
16
+ })
17
+
18
+ it("écarte 0 (état « fermé »), les hors-bornes et le non numérique", () => {
19
+ expect(normalizeBreakpoints([0, 0.25, 1.5, -0.5])).toEqual([0.25])
20
+ expect(normalizeBreakpoints([Number.NaN, Number.POSITIVE_INFINITY])).toEqual([])
21
+ expect(normalizeBreakpoints(["0.25", "0.5"])).toEqual([0.25, 0.5])
22
+ })
23
+
24
+ it("accepte une chaîne (usage en attribut)", () => {
25
+ expect(normalizeBreakpoints("0.25, 0.5")).toEqual([0.25, 0.5])
26
+ expect(normalizeBreakpoints("[0.25, 0.5, 0.75]")).toEqual([0.25, 0.5, 0.75])
27
+ expect(normalizeBreakpoints("")).toEqual([])
28
+ })
29
+
30
+ it("rend une liste vide pour une valeur absente ou illisible", () => {
31
+ expect(normalizeBreakpoints()).toEqual([])
32
+ expect(normalizeBreakpoints(null)).toEqual([])
33
+ expect(normalizeBreakpoints(0.5)).toEqual([])
34
+ expect(normalizeBreakpoints({})).toEqual([])
35
+ })
36
+ })
37
+
38
+ describe("nearestBreakpoint", () => {
39
+ const anchors = [0.25, 0.5, 0.75]
40
+
41
+ it("choisit le point d'ancrage le plus proche (le plus bas à égalité)", () => {
42
+ expect(nearestBreakpoint(0.3, anchors)).toBe(0.25)
43
+ expect(nearestBreakpoint(0.4, anchors)).toBe(0.5)
44
+ expect(nearestBreakpoint(0.375, anchors)).toBe(0.25)
45
+ expect(nearestBreakpoint(0.9, anchors)).toBe(0.75)
46
+ })
47
+
48
+ it("borne la valeur quand la liste est vide", () => {
49
+ expect(nearestBreakpoint(1.4, [])).toBe(1)
50
+ expect(nearestBreakpoint(-2, [])).toBe(0)
51
+ })
52
+ })
53
+
54
+ describe("stepBreakpoint", () => {
55
+ const anchors = [0.25, 0.5, 0.75]
56
+
57
+ it("monte et descend d'un cran depuis le point d'ancrage courant", () => {
58
+ expect(stepBreakpoint(0.25, anchors, 1)).toBe(0.5)
59
+ expect(stepBreakpoint(0.5, anchors, 1)).toBe(0.75)
60
+ expect(stepBreakpoint(0.75, anchors, -1)).toBe(0.5)
61
+ })
62
+
63
+ it("reste sur place aux extrémités", () => {
64
+ expect(stepBreakpoint(0.75, anchors, 1)).toBe(0.75)
65
+ expect(stepBreakpoint(0.25, anchors, -1)).toBe(0.25)
66
+ })
67
+
68
+ it("accepte une liste non triée et une valeur intermédiaire", () => {
69
+ expect(stepBreakpoint(0.6, [0.25, 0.75, 0.5], 1)).toBe(0.75)
70
+ })
71
+ })
72
+
73
+ describe("clampRatio / ratioFromDrag", () => {
74
+ it("borne une fraction et protège le non numérique", () => {
75
+ expect(clampRatio(1.4)).toBe(1)
76
+ expect(clampRatio(-0.2)).toBe(0)
77
+ expect(clampRatio(Number.NaN)).toBe(0)
78
+ expect(clampRatio(0.4, 0.25, 0.75)).toBe(0.4)
79
+ })
80
+
81
+ it("convertit un drag vertical en fraction (vers le haut = agrandir)", () => {
82
+ // 100 px vers le haut dans une vue de 800 px → +0.125
83
+ expect(ratioFromDrag(0.5, -100, 800)).toBeCloseTo(0.625, 5)
84
+ expect(ratioFromDrag(0.5, 200, 800)).toBeCloseTo(0.25, 5)
85
+ expect(ratioFromDrag(0.5, 9999, 800)).toBe(0)
86
+ })
87
+
88
+ it("rend la fraction de départ si la hauteur de vue est inutilisable", () => {
89
+ expect(ratioFromDrag(0.5, 100, 0)).toBe(0.5)
90
+ expect(ratioFromDrag(0.5, Number.NaN, 800)).toBe(0.5)
91
+ })
92
+ })
93
+
94
+ describe("releaseBreakpoint", () => {
95
+ const anchors = [0.25, 0.5, 0.75]
96
+
97
+ it("se pose sur le point d'ancrage le plus proche", () => {
98
+ expect(releaseBreakpoint(0.28, anchors)).toEqual({ close: false, breakpoint: 0.25 })
99
+ expect(releaseBreakpoint(0.7, anchors)).toEqual({ close: false, breakpoint: 0.75 })
100
+ expect(releaseBreakpoint(1, anchors)).toEqual({ close: false, breakpoint: 0.75 })
101
+ })
102
+
103
+ it("ferme le panneau relâché à 0 ou sous le plus bas point d'ancrage", () => {
104
+ expect(releaseBreakpoint(0, anchors)).toEqual({ close: true })
105
+ expect(releaseBreakpoint(-0.1, anchors)).toEqual({ close: true })
106
+ // 0.05 est à plus de 0.15 sous 0.25 → fermeture
107
+ expect(releaseBreakpoint(0.05, anchors)).toEqual({ close: true })
108
+ // 0.15 reste dans la tolérance → on remonte sur 0.25
109
+ expect(releaseBreakpoint(0.15, anchors)).toEqual({ close: false, breakpoint: 0.25 })
110
+ })
111
+
112
+ it("respecte un seuil de fermeture explicite", () => {
113
+ expect(releaseBreakpoint(0.2, anchors, 0)).toEqual({ close: true })
114
+ expect(releaseBreakpoint(0.2, anchors, 0.5)).toEqual({ close: false, breakpoint: 0.25 })
115
+ })
116
+
117
+ it("ferme toujours sans point d'ancrage exploitable", () => {
118
+ expect(releaseBreakpoint(0.5, [])).toEqual({ close: true })
119
+ expect(releaseBreakpoint(Number.NaN, anchors)).toEqual({ close: true })
120
+ })
121
+ })
@@ -0,0 +1,115 @@
1
+ // bottomSheet — points d'ancrage (`breakpoints`) d'un bottom sheet : les hauteurs
2
+ // relatives où le panneau peut se poser, et les calculs de drag / snap associés.
3
+ // Pur (aucun DOM) → testable hors navigateur, comme `lib/layout.ts`.
4
+ //
5
+ // Convention : un point d'ancrage est une **fraction de la hauteur de vue** (0 → 1),
6
+ // comme chez Ionic (`:breakpoints="[0.25, 0.5, 0.75]"`). `0` est accepté dans la liste
7
+ // mais retiré à la normalisation : il signifie « fermé », un état porté par `v-model`.
8
+
9
+ const toNumber = (value: unknown): number | undefined => {
10
+ if (typeof value === "number") return Number.isFinite(value) ? value : undefined
11
+ if (typeof value !== "string" || value.trim() === "") return undefined
12
+ const n = Number(value)
13
+ return Number.isFinite(n) ? n : undefined
14
+ }
15
+
16
+ /**
17
+ * Points d'ancrage exploitables : nombres finis de ]0, 1], dédoublonnés et **croissants**.
18
+ * Accepte aussi une chaîne (`"0.25,0.5"` ou `"[0.25, 0.5]"`) pour l'usage en attribut.
19
+ */
20
+ export function normalizeBreakpoints(breakpoints?: unknown): number[] {
21
+ let raw: unknown = breakpoints
22
+
23
+ if (typeof raw === "string") {
24
+ raw = raw
25
+ .replace(/[[\]()]/g, "")
26
+ .split(/[,;\s]+/)
27
+ .filter(Boolean)
28
+ }
29
+
30
+ if (!Array.isArray(raw)) return []
31
+
32
+ const values = raw
33
+ .map((value) => toNumber(value))
34
+ .filter((value): value is number => value !== undefined && value > 0 && value <= 1)
35
+
36
+ return [...new Set(values)].sort((a, b) => a - b)
37
+ }
38
+
39
+ /** Fraction bornée à [min, max] (défaut `0 → 1`) — valeur illisible → `min`. */
40
+ export function clampRatio(value: number, min = 0, max = 1): number {
41
+ if (!Number.isFinite(value)) return min
42
+ return Math.min(max, Math.max(min, value))
43
+ }
44
+
45
+ /**
46
+ * Point d'ancrage le plus proche de `value` — à égalité, le plus bas.
47
+ * `breakpoints` vide → la valeur bornée à [0, 1].
48
+ */
49
+ export function nearestBreakpoint(value: number, breakpoints: readonly number[]): number {
50
+ if (breakpoints.length === 0) return clampRatio(value)
51
+
52
+ let best = breakpoints[0]!
53
+ for (const candidate of breakpoints) {
54
+ if (Math.abs(candidate - value) < Math.abs(best - value)) best = candidate
55
+ }
56
+ return best
57
+ }
58
+
59
+ /**
60
+ * Point d'ancrage voisin dans une direction (`1` = plus haut, `-1` = plus bas), borné :
61
+ * aux extrémités, la valeur courante est rendue telle quelle. Sert au cycle clavier
62
+ * (touche Entrée sur la poignée) et à l'API exposée.
63
+ */
64
+ export function stepBreakpoint(
65
+ value: number,
66
+ breakpoints: readonly number[],
67
+ direction: 1 | -1,
68
+ ): number {
69
+ if (breakpoints.length === 0) return clampRatio(value)
70
+
71
+ const ordered = [...breakpoints].sort((a, b) => a - b)
72
+ const current = nearestBreakpoint(value, ordered)
73
+ const index = ordered.indexOf(current)
74
+ const next = index + direction
75
+ if (next < 0 || next >= ordered.length) return current
76
+ return ordered[next]!
77
+ }
78
+
79
+ /** Fraction atteinte après un drag vertical : un geste vers le haut (`dy < 0`) agrandit. */
80
+ export function ratioFromDrag(startRatio: number, dy: number, viewportHeight: number): number {
81
+ if (!Number.isFinite(dy) || !Number.isFinite(viewportHeight) || viewportHeight <= 0) {
82
+ return clampRatio(startRatio)
83
+ }
84
+ return clampRatio(startRatio - dy / viewportHeight)
85
+ }
86
+
87
+ /** Décision prise au relâchement du drag : se poser sur un point d'ancrage, ou fermer. */
88
+ export interface BreakpointRelease {
89
+ /** Le panneau doit se fermer */
90
+ close: boolean
91
+ /** Point d'ancrage retenu (absent quand `close`) */
92
+ breakpoint?: number
93
+ }
94
+
95
+ /**
96
+ * Où se poser au relâchement : sous le plus bas point d'ancrage de plus de
97
+ * `closeThreshold` (fraction), ou à 0 → **fermeture** ; sinon le point d'ancrage le plus
98
+ * proche. Garde la règle historique du composant — un drag vers le bas ferme le panneau,
99
+ * même quand `0` n'est pas dans la liste (Ionic, lui, ne ferme plus dans ce cas).
100
+ */
101
+ export function releaseBreakpoint(
102
+ ratio: number,
103
+ breakpoints: readonly number[],
104
+ closeThreshold = 0.15,
105
+ ): BreakpointRelease {
106
+ if (!Number.isFinite(ratio) || ratio <= 0) return { close: true }
107
+
108
+ const ordered = [...breakpoints].sort((a, b) => a - b)
109
+ if (ordered.length === 0) return { close: true }
110
+
111
+ const lowest = ordered[0]!
112
+ if (ratio < lowest - Math.max(0, closeThreshold)) return { close: true }
113
+
114
+ return { breakpoint: nearestBreakpoint(ratio, ordered), close: false }
115
+ }