@dnax/ui 0.0.70 → 0.0.72
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/QSpreadsheet.vue +61 -7
- package/lib/spreadsheet.test.ts +39 -0
- package/lib/spreadsheet.ts +68 -0
- package/package.json +1 -1
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
// édition en place (Entrée / F2 / double-clic / frappe directe),
|
|
5
5
|
// lignes & colonnes (ajouter / insérer / supprimer),
|
|
6
6
|
// tri, copier/coller, undo/redo, et cellules typées
|
|
7
|
-
//
|
|
7
|
+
// texte, nombre, entier, booléen, date, datetime, **time (heure)**, select / multiselect avec
|
|
8
|
+
// badges colorés).
|
|
8
9
|
// Suivi des modifications : `v-model:dirty` (état) + `v-model:changes` (delta lignes / feuilles
|
|
9
10
|
// ajoutées, modifiées, supprimées), calculé en comparant le document à une **référence**
|
|
10
11
|
// (`lib/spreadsheetChanges.ts`).
|
|
@@ -44,6 +45,7 @@ export type QSpreadsheetCellType =
|
|
|
44
45
|
| "boolean"
|
|
45
46
|
| "date"
|
|
46
47
|
| "datetime"
|
|
48
|
+
| "time"
|
|
47
49
|
| "select"
|
|
48
50
|
| "multiselect"
|
|
49
51
|
|
|
@@ -185,6 +187,9 @@ import {
|
|
|
185
187
|
isBlankValue,
|
|
186
188
|
ISO_DATE,
|
|
187
189
|
isoAddDays,
|
|
190
|
+
normalizeTime,
|
|
191
|
+
secondsToTime,
|
|
192
|
+
timeToSeconds,
|
|
188
193
|
shiftFormulaRefs,
|
|
189
194
|
functionQueryAt,
|
|
190
195
|
rankSuggestions,
|
|
@@ -777,6 +782,8 @@ const moveSelection = (dr: number, dc: number, extend = false) => {
|
|
|
777
782
|
const escAttr = (s: string) => String(s).replace(/"/g, '\\"')
|
|
778
783
|
const scrollEl = ref<HTMLElement | null>(null)
|
|
779
784
|
const rootEl = ref<HTMLElement | null>(null)
|
|
785
|
+
/** Racine du composant (pour distinguer un clic intérieur d'un clic extérieur) */
|
|
786
|
+
const rootRef = ref<HTMLElement | null>(null)
|
|
780
787
|
const setGridRef = (el: unknown) => {
|
|
781
788
|
rootEl.value = (el as HTMLElement) ?? null
|
|
782
789
|
scrollEl.value = (el as HTMLElement) ?? null
|
|
@@ -877,6 +884,8 @@ const cellText = (row: number, column: string): string => {
|
|
|
877
884
|
let out: any = ev === null || ev === undefined ? "" : ev
|
|
878
885
|
// Apostrophe littérale (paste values) : affichée sans le préfixe
|
|
879
886
|
if (typeof out === "string" && out.startsWith("'")) out = out.slice(1)
|
|
887
|
+
// Heure : toujours `HH:MM` à l'affichage (données importées ou anciennes comprises)
|
|
888
|
+
if (col?.type === "time" && typeof out === "string") out = normalizeTime(out) ?? out
|
|
880
889
|
if (col?.format && out !== "") out = col.format(out, state.value[row] ?? {})
|
|
881
890
|
if (out === null || out === undefined || out === "") return ""
|
|
882
891
|
return String(out)
|
|
@@ -988,6 +997,8 @@ const editorInputType = computed(() => {
|
|
|
988
997
|
return "date"
|
|
989
998
|
case "datetime":
|
|
990
999
|
return "datetime-local"
|
|
1000
|
+
case "time":
|
|
1001
|
+
return "time"
|
|
991
1002
|
default:
|
|
992
1003
|
// PAS de `type="number"` pour number/integer : (1) le navigateur refuse
|
|
993
1004
|
// `=` (formule) ou un "-" isolé dans un input number, (2) `v-model` sur
|
|
@@ -1022,6 +1033,8 @@ const startEdit = (row: number, column: string, initial?: string) => {
|
|
|
1022
1033
|
} else {
|
|
1023
1034
|
let rawStr = String(raw ?? "")
|
|
1024
1035
|
if (rawStr.startsWith("'")) rawStr = rawStr.slice(1)
|
|
1036
|
+
// Heure : l'input natif n'accepte que `HH:MM` — on normalise dès l'ouverture
|
|
1037
|
+
if (col?.type === "time") rawStr = normalizeTime(rawStr) ?? rawStr
|
|
1025
1038
|
draft.value = String(initial ?? rawStr)
|
|
1026
1039
|
}
|
|
1027
1040
|
editing.value = { row, column }
|
|
@@ -1099,6 +1112,11 @@ const coerceValue = (col: QSpreadsheetColumn | undefined, text: unknown, old: an
|
|
|
1099
1112
|
if (col?.type === "boolean") {
|
|
1100
1113
|
return raw === "true" ? true : raw === "false" ? false : old
|
|
1101
1114
|
}
|
|
1115
|
+
if (col?.type === "time") {
|
|
1116
|
+
// Heure valide → `HH:MM` (`HH:MM:SS` si les secondes sont non nulles) ; invalide → ancienne
|
|
1117
|
+
const time = normalizeTime(raw)
|
|
1118
|
+
return time === undefined ? old : time
|
|
1119
|
+
}
|
|
1102
1120
|
if (col?.type === "select") {
|
|
1103
1121
|
const opt = cellOptionsOf(col).find(
|
|
1104
1122
|
(o) => String(o.label ?? "") === raw || String(o.value) === raw,
|
|
@@ -1122,7 +1140,7 @@ const coerceValue = (col: QSpreadsheetColumn | undefined, text: unknown, old: an
|
|
|
1122
1140
|
return raw
|
|
1123
1141
|
}
|
|
1124
1142
|
|
|
1125
|
-
const commitEdit = async () => {
|
|
1143
|
+
const commitEdit = async (options: { focus?: boolean } = {}) => {
|
|
1126
1144
|
if (!editing.value) return
|
|
1127
1145
|
const { row, column } = editing.value
|
|
1128
1146
|
const col = colOf(column)
|
|
@@ -1133,7 +1151,10 @@ const commitEdit = async () => {
|
|
|
1133
1151
|
const next = col?.type === "multiselect" ? old : coerceValue(col, text, old)
|
|
1134
1152
|
editing.value = null
|
|
1135
1153
|
emit("cell-edit-end", { row, column, canceled: false })
|
|
1136
|
-
|
|
1154
|
+
// Le focus n'est **pas** restauré par défaut : un `blur` (clic hors de la grille)
|
|
1155
|
+
// ne doit pas voler le focus à l'élément cliqué. Seuls les appels internes qui le
|
|
1156
|
+
// demandent explicitement (`pickOption`) le font.
|
|
1157
|
+
if (options.focus) nextTick(() => focusCell(row, column))
|
|
1137
1158
|
if (next !== old) await validateAndSet(row, column, old, next)
|
|
1138
1159
|
}
|
|
1139
1160
|
|
|
@@ -1898,7 +1919,8 @@ watch(selectOptions, () => {
|
|
|
1898
1919
|
})
|
|
1899
1920
|
const pickOption = (opt: QSpreadsheetCellOption) => {
|
|
1900
1921
|
draft.value = String(opt.label ?? "")
|
|
1901
|
-
|
|
1922
|
+
// Choix fait **dans** la liste : le focus revient utilement à la cellule
|
|
1923
|
+
commitEdit({ focus: true })
|
|
1902
1924
|
}
|
|
1903
1925
|
|
|
1904
1926
|
/** Valeur courante (tableau) de la cellule à choix multiples en cours d'édition */
|
|
@@ -2787,6 +2809,13 @@ const onColContext = (ci: number, e: MouseEvent) => {
|
|
|
2787
2809
|
const onDocPointerDown = (e: PointerEvent) => {
|
|
2788
2810
|
const t = e.target as HTMLElement
|
|
2789
2811
|
if (!t || !t.closest) return
|
|
2812
|
+
// Un clic **hors de l'éditeur** ferme l'édition — sans lui voler le focus :
|
|
2813
|
+
// le `blur` ne suffit pas si la cible n'est pas focusable (fond de page, `<div>`…).
|
|
2814
|
+
// Condition : ne rien faire seulement si le clic est dans **notre** éditeur (une autre
|
|
2815
|
+
// instance du tableur peut avoir le sien ouvert).
|
|
2816
|
+
if (editing.value && !(rootRef.value?.contains(t) && t.closest(".q-spreadsheet__editor"))) {
|
|
2817
|
+
commitEdit()
|
|
2818
|
+
}
|
|
2790
2819
|
if (
|
|
2791
2820
|
(ctxMenu.value && !t.closest(".q-spreadsheet__ctx")) ||
|
|
2792
2821
|
(filterMenu.value && !t.closest(".q-spreadsheet__fpop") && !t.closest(".q-spreadsheet__fbtn"))
|
|
@@ -3065,6 +3094,19 @@ const applyFill = () => {
|
|
|
3065
3094
|
const d1 = +new Date(s1 + "T00:00:00Z")
|
|
3066
3095
|
const step = Math.round((d1 - d0) / 86400000)
|
|
3067
3096
|
if (step !== 0) val = isoAddDays(s1, step * (rr - b.r1))
|
|
3097
|
+
} else if (
|
|
3098
|
+
b.r1 > b.r0 &&
|
|
3099
|
+
col?.type === "time" &&
|
|
3100
|
+
typeof s0 === "string" &&
|
|
3101
|
+
typeof s1 === "string"
|
|
3102
|
+
) {
|
|
3103
|
+
// Série d'heures : 09:00 puis 10:00 → 11:00, 12:00… (modulo 24 h)
|
|
3104
|
+
const t0 = timeToSeconds(s0)
|
|
3105
|
+
const t1 = timeToSeconds(s1)
|
|
3106
|
+
if (t0 !== undefined && t1 !== undefined && t1 !== t0) {
|
|
3107
|
+
const step = (t1 - t0) / (b.r1 - b.r0)
|
|
3108
|
+
val = secondsToTime(t1 + step * (rr - b.r1))
|
|
3109
|
+
}
|
|
3068
3110
|
}
|
|
3069
3111
|
} else if (
|
|
3070
3112
|
!fillCopy.value &&
|
|
@@ -3081,6 +3123,18 @@ const applyFill = () => {
|
|
|
3081
3123
|
if (b.c1 > b.c0 && typeof s0 === "number" && typeof s1 === "number" && s1 !== s0) {
|
|
3082
3124
|
const step = (s1 - s0) / (b.c1 - b.c0)
|
|
3083
3125
|
val = s1 + step * (cc - b.c1)
|
|
3126
|
+
} else if (
|
|
3127
|
+
b.c1 > b.c0 &&
|
|
3128
|
+
col?.type === "time" &&
|
|
3129
|
+
typeof s0 === "string" &&
|
|
3130
|
+
typeof s1 === "string"
|
|
3131
|
+
) {
|
|
3132
|
+
const t0 = timeToSeconds(s0)
|
|
3133
|
+
const t1 = timeToSeconds(s1)
|
|
3134
|
+
if (t0 !== undefined && t1 !== undefined && t1 !== t0) {
|
|
3135
|
+
const step = (t1 - t0) / (b.c1 - b.c0)
|
|
3136
|
+
val = secondsToTime(t1 + step * (cc - b.c1))
|
|
3137
|
+
}
|
|
3084
3138
|
}
|
|
3085
3139
|
}
|
|
3086
3140
|
// Recopie du motif (source cyclique) — vers le bas, la droite, le haut,
|
|
@@ -4576,7 +4630,7 @@ defineExpose({
|
|
|
4576
4630
|
</script>
|
|
4577
4631
|
|
|
4578
4632
|
<template>
|
|
4579
|
-
<div class="q-spreadsheet" :class="rootClasses" :style="radiusStyle(effectiveRadius)">
|
|
4633
|
+
<div ref="rootRef" class="q-spreadsheet" :class="rootClasses" :style="radiusStyle(effectiveRadius)">
|
|
4580
4634
|
<!-- ═══════ Onglets de feuilles (classeur, haut) ═══════ -->
|
|
4581
4635
|
<div
|
|
4582
4636
|
v-if="sheetsMode && sheetsPosition === 'top'"
|
|
@@ -5111,7 +5165,7 @@ defineExpose({
|
|
|
5111
5165
|
@keyup="syncEditCaret"
|
|
5112
5166
|
@click="syncEditCaret"
|
|
5113
5167
|
@keydown="onEditKeydown"
|
|
5114
|
-
@blur="commitEdit"
|
|
5168
|
+
@blur="commitEdit()"
|
|
5115
5169
|
/>
|
|
5116
5170
|
<input
|
|
5117
5171
|
v-else
|
|
@@ -5122,7 +5176,7 @@ defineExpose({
|
|
|
5122
5176
|
:inputmode="editorInputMode"
|
|
5123
5177
|
:placeholder="editingIsSelect ? 'Type to filter…' : editingIsMulti ? 'Filter options…' : ''"
|
|
5124
5178
|
@keydown="onEditKeydown"
|
|
5125
|
-
@blur="commitEdit"
|
|
5179
|
+
@blur="commitEdit()"
|
|
5126
5180
|
/>
|
|
5127
5181
|
<div v-if="editingIsFormula && fxHint && !fxOpen" class="q-spreadsheet__fxhint q-spreadsheet__fxhint--editor">
|
|
5128
5182
|
<strong>{{ fxHint.name }}</strong>(<template v-for="(arg, i) in fxHint.args" :key="i"><span
|
package/lib/spreadsheet.test.ts
CHANGED
|
@@ -14,8 +14,47 @@ import {
|
|
|
14
14
|
csvSplitLine,
|
|
15
15
|
parseCsv,
|
|
16
16
|
deriveCols,
|
|
17
|
+
normalizeTime,
|
|
18
|
+
parseTimeOfDay,
|
|
19
|
+
secondsToTime,
|
|
20
|
+
timeToSeconds,
|
|
17
21
|
} from "./spreadsheet"
|
|
18
22
|
|
|
23
|
+
describe("heures (type `time`)", () => {
|
|
24
|
+
it("décompose les formes usuelles", () => {
|
|
25
|
+
expect(parseTimeOfDay("09:30")).toEqual({ hours: 9, minutes: 30, seconds: 0 })
|
|
26
|
+
expect(parseTimeOfDay("9h5")).toEqual({ hours: 9, minutes: 5, seconds: 0 })
|
|
27
|
+
expect(parseTimeOfDay("9")).toEqual({ hours: 9, minutes: 0, seconds: 0 })
|
|
28
|
+
expect(parseTimeOfDay("23:59:58")).toEqual({ hours: 23, minutes: 59, seconds: 58 })
|
|
29
|
+
// vide → cellule effacée ; null/undefined → pas une heure
|
|
30
|
+
expect(parseTimeOfDay("")).toBeNull()
|
|
31
|
+
expect(parseTimeOfDay(null)).toBeUndefined()
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it("refuse les heures invalides", () => {
|
|
35
|
+
for (const value of ["24:00", "9:75", "9:30:75", "abc", "1:2:3:4", "-1:00"]) {
|
|
36
|
+
expect(parseTimeOfDay(value), value).toBeUndefined()
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it("normalise en `HH:MM` (secondes conservées si non nulles)", () => {
|
|
41
|
+
expect(normalizeTime("9:5")).toBe("09:05")
|
|
42
|
+
expect(normalizeTime("9h30")).toBe("09:30")
|
|
43
|
+
expect(normalizeTime("09:30:00")).toBe("09:30")
|
|
44
|
+
expect(normalizeTime("09:30:07")).toBe("09:30:07")
|
|
45
|
+
expect(normalizeTime("")).toBeNull()
|
|
46
|
+
expect(normalizeTime("nope")).toBeUndefined()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it("séries d'heures : secondes, modulo 24 h, valeurs négatives", () => {
|
|
50
|
+
expect(timeToSeconds("01:00")).toBe(3600)
|
|
51
|
+
expect(secondsToTime(3600)).toBe("01:00")
|
|
52
|
+
expect(secondsToTime(25 * 3600)).toBe("01:00")
|
|
53
|
+
expect(secondsToTime(-3600)).toBe("23:00")
|
|
54
|
+
expect(secondsToTime(3661)).toBe("01:01:01")
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
19
58
|
describe("lettres de colonnes", () => {
|
|
20
59
|
it("colLetter : 0→A, 25→Z, 26→AA, 27→AB, 701→ZZ, 702→AAA", () => {
|
|
21
60
|
expect(colLetter(0)).toBe("A")
|
package/lib/spreadsheet.ts
CHANGED
|
@@ -247,6 +247,74 @@ export const isoAddDays = (iso: string, days: number): string => {
|
|
|
247
247
|
return d.toISOString().slice(0, 10)
|
|
248
248
|
}
|
|
249
249
|
|
|
250
|
+
/** Heure du jour : `HH:MM` ou `HH:MM:SS` (toujours sur deux chiffres) */
|
|
251
|
+
export const TIME_OF_DAY = /^\d{2}:\d{2}(?::\d{2})?$/
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Décompose une **heure** saisie librement. Accepte `9`, `09:30`, `9:5`, `9h30`, `09:30:15`.
|
|
255
|
+
*
|
|
256
|
+
* - `null` → chaîne vide (cellule effacée) ;
|
|
257
|
+
* - `undefined` → ce n'est pas une heure valide (`24:00`, `9:75`, `abc`…).
|
|
258
|
+
*
|
|
259
|
+
* Le parsing est volontairement **local** (pas de `Date`) : aucune conversion de fuseau ne
|
|
260
|
+
* doit intervenir sur une heure de la journée.
|
|
261
|
+
*/
|
|
262
|
+
export function parseTimeOfDay(
|
|
263
|
+
value: any,
|
|
264
|
+
): { hours: number; minutes: number; seconds: number } | null | undefined {
|
|
265
|
+
if (value === null || value === undefined) return undefined
|
|
266
|
+
const raw = String(value).trim()
|
|
267
|
+
if (raw === "") return null
|
|
268
|
+
const match = raw.match(/^(\d{1,2})(?:[:h](\d{1,2}))?(?::(\d{1,2}))?$/i)
|
|
269
|
+
if (!match) return undefined
|
|
270
|
+
const hours = Number(match[1])
|
|
271
|
+
const minutes = match[2] === undefined ? 0 : Number(match[2])
|
|
272
|
+
const seconds = match[3] === undefined ? 0 : Number(match[3])
|
|
273
|
+
if (
|
|
274
|
+
!Number.isInteger(hours) ||
|
|
275
|
+
!Number.isInteger(minutes) ||
|
|
276
|
+
!Number.isInteger(seconds) ||
|
|
277
|
+
hours > 23 ||
|
|
278
|
+
minutes > 59 ||
|
|
279
|
+
seconds > 59
|
|
280
|
+
) {
|
|
281
|
+
return undefined
|
|
282
|
+
}
|
|
283
|
+
return { hours, minutes, seconds }
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const two = (n: number) => String(n).padStart(2, "0")
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Normalise une heure en `HH:MM` — les secondes ne sont conservées que si elles sont non
|
|
290
|
+
* nulles (`HH:MM:SS`). `null` = vide, `undefined` = invalide (l'appelant garde l'ancienne
|
|
291
|
+
* valeur, comme pour un nombre refusé).
|
|
292
|
+
*/
|
|
293
|
+
export function normalizeTime(value: any): string | null | undefined {
|
|
294
|
+
const parsed = parseTimeOfDay(value)
|
|
295
|
+
if (parsed === null || parsed === undefined) return parsed
|
|
296
|
+
const base = `${two(parsed.hours)}:${two(parsed.minutes)}`
|
|
297
|
+
return parsed.seconds ? `${base}:${two(parsed.seconds)}` : base
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Secondes depuis minuit (séries d'auto-remplissage) — `undefined` si invalide */
|
|
301
|
+
export function timeToSeconds(value: any): number | undefined {
|
|
302
|
+
const parsed = parseTimeOfDay(value)
|
|
303
|
+
if (!parsed) return undefined
|
|
304
|
+
return parsed.hours * 3600 + parsed.minutes * 60 + parsed.seconds
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Secondes depuis minuit → `HH:MM` (`HH:MM:SS` si nécessaire), **modulo 24 h** */
|
|
308
|
+
export function secondsToTime(total: number): string {
|
|
309
|
+
if (!Number.isFinite(total)) return "00:00"
|
|
310
|
+
const day = 86400
|
|
311
|
+
const normalized = ((Math.round(total) % day) + day) % day
|
|
312
|
+
const hours = Math.floor(normalized / 3600)
|
|
313
|
+
const minutes = Math.floor((normalized % 3600) / 60)
|
|
314
|
+
const seconds = normalized % 60
|
|
315
|
+
return seconds ? `${two(hours)}:${two(minutes)}:${two(seconds)}` : `${two(hours)}:${two(minutes)}`
|
|
316
|
+
}
|
|
317
|
+
|
|
250
318
|
/** Échappe une valeur pour un CSV (quote si délimiteur / guillemet / newline) */
|
|
251
319
|
export const csvEscape = (s: unknown, delim: string): string => {
|
|
252
320
|
const str = s === null || s === undefined ? "" : String(s)
|