@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.
@@ -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
- // (texte, nombre, entier, booléen, date, datetime, select / multiselect avec badges colorés).
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
- nextTick(() => focusCell(row, column))
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
- commitEdit()
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
@@ -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")
@@ -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)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dnax/ui",
3
- "version": "0.0.70",
3
+ "version": "0.0.72",
4
4
  "module": "index.ts",
5
5
  "description": "UI design System",
6
6
  "type": "module",