@eifi1/ui-kit 0.24.1 → 0.25.1

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 (148) hide show
  1. package/README.md +6 -6
  2. package/dist/chart.d.ts +2 -2
  3. package/dist/components/account-chips.d.ts +1 -1
  4. package/dist/components/amount-input.d.ts +2 -2
  5. package/dist/components/button-group.d.ts +2 -2
  6. package/dist/components/calculator.d.ts +2 -2
  7. package/dist/components/column-mapper.d.ts +28 -9
  8. package/dist/components/column-mapper.js +63 -77
  9. package/dist/components/column-mapper.js.map +1 -1
  10. package/dist/components/confirm-dialog.d.ts +2 -2
  11. package/dist/components/copy-button.d.ts +2 -2
  12. package/dist/components/danger-confirm.d.ts +2 -2
  13. package/dist/components/data-table-cells.d.ts +1 -1
  14. package/dist/components/data-table-filter-popover.d.ts +1 -1
  15. package/dist/components/data-table-filters.d.ts +1 -1
  16. package/dist/components/data-table-labels.d.ts +1 -1
  17. package/dist/components/data-table-pagination.d.ts +1 -1
  18. package/dist/components/data-table-pagination.js +6 -1
  19. package/dist/components/data-table-pagination.js.map +1 -1
  20. package/dist/components/data-table.d.ts +1 -1
  21. package/dist/components/data-table.js +7 -1
  22. package/dist/components/data-table.js.map +1 -1
  23. package/dist/components/facing-pair.d.ts +2 -2
  24. package/dist/components/file-button.d.ts +2 -2
  25. package/dist/components/file-dropzone.d.ts +2 -2
  26. package/dist/components/form-actions.d.ts +2 -2
  27. package/dist/components/iban-input.d.ts +2 -2
  28. package/dist/components/language-select.d.ts +2 -2
  29. package/dist/components/money-field.d.ts +2 -2
  30. package/dist/components/number-field.d.ts +2 -2
  31. package/dist/components/number-input.d.ts +2 -2
  32. package/dist/components/numpad-sheet.d.ts +2 -2
  33. package/dist/components/phone-input.d.ts +2 -2
  34. package/dist/components/series-chart.d.ts +2 -2
  35. package/dist/components/settings-fields.d.ts +2 -2
  36. package/dist/components/share-card.d.ts +2 -2
  37. package/dist/components/swipeable-row.js +1 -1
  38. package/dist/components/swipeable-row.js.map +1 -1
  39. package/dist/components/table.d.ts +35 -1
  40. package/dist/components/table.js +4 -0
  41. package/dist/components/table.js.map +1 -1
  42. package/dist/components/text-link.d.ts +2 -2
  43. package/dist/components/time-input.d.ts +2 -2
  44. package/dist/components/tooltip.d.ts +103 -14
  45. package/dist/components/tooltip.js +194 -57
  46. package/dist/components/tooltip.js.map +1 -1
  47. package/dist/components/translation-review-editor.d.ts +9 -1
  48. package/dist/components/translation-review-editor.js +11 -1
  49. package/dist/components/translation-review-editor.js.map +1 -1
  50. package/dist/components/translation-review-labels.d.ts +10 -0
  51. package/dist/components/translation-review-labels.js +5 -1
  52. package/dist/components/translation-review-labels.js.map +1 -1
  53. package/dist/components/translation-review.d.ts +145 -8
  54. package/dist/components/translation-review.js +372 -91
  55. package/dist/components/translation-review.js.map +1 -1
  56. package/dist/components/ui.d.ts +2 -2
  57. package/dist/components/ui.js +55 -18
  58. package/dist/components/ui.js.map +1 -1
  59. package/dist/components/use-table-state.d.ts +1 -1
  60. package/dist/{data-table-labels-B7OdnM0S.d.ts → data-table-labels-Cdn1QS2k.d.ts} +21 -1
  61. package/dist/data-table.d.ts +1 -1
  62. package/dist/feedback/feedback-attachment.d.ts +2 -2
  63. package/dist/feedback/feedback-dialog.d.ts +2 -2
  64. package/dist/feedback/feedback-dialog.js +6 -1
  65. package/dist/feedback/feedback-dialog.js.map +1 -1
  66. package/dist/feedback/feedback-inbox.d.ts +2 -2
  67. package/dist/feedback/feedback-inbox.js +2 -0
  68. package/dist/feedback/feedback-inbox.js.map +1 -1
  69. package/dist/feedback/feedback-thread.d.ts +2 -2
  70. package/dist/{feedback-DOwPu-Il.d.ts → feedback-BXKH-ToU.d.ts} +32 -3
  71. package/dist/feedback.d.ts +2 -2
  72. package/dist/hooks/use-body-scroll-lock.js +16 -0
  73. package/dist/hooks/use-body-scroll-lock.js.map +1 -1
  74. package/dist/hooks/use-file-drop.d.ts +2 -2
  75. package/dist/hooks/use-search-param-state.d.ts +58 -3
  76. package/dist/hooks/use-search-param-state.js +107 -35
  77. package/dist/hooks/use-search-param-state.js.map +1 -1
  78. package/dist/i18n/defaults.d.ts +2 -2
  79. package/dist/i18n/german.d.ts +2 -2
  80. package/dist/i18n/german.js +8 -1
  81. package/dist/i18n/german.js.map +1 -1
  82. package/dist/i18n/kit-labels.d.ts +2 -2
  83. package/dist/i18n/languages.d.ts +2 -2
  84. package/dist/i18n/locales/de-CH.d.ts +2 -2
  85. package/dist/i18n/locales/en.d.ts +2 -2
  86. package/dist/i18n/locales/en.js +5 -1
  87. package/dist/i18n/locales/en.js.map +1 -1
  88. package/dist/i18n/locales/es.d.ts +2 -2
  89. package/dist/i18n/locales/es.js +7 -1
  90. package/dist/i18n/locales/es.js.map +1 -1
  91. package/dist/i18n/locales/fr.d.ts +2 -2
  92. package/dist/i18n/locales/fr.js +6 -1
  93. package/dist/i18n/locales/fr.js.map +1 -1
  94. package/dist/i18n/locales/hu.d.ts +2 -2
  95. package/dist/i18n/locales/hu.js +9 -1
  96. package/dist/i18n/locales/hu.js.map +1 -1
  97. package/dist/i18n/locales/it.d.ts +2 -2
  98. package/dist/i18n/locales/it.js +6 -1
  99. package/dist/i18n/locales/it.js.map +1 -1
  100. package/dist/i18n/locales/zh.d.ts +2 -2
  101. package/dist/i18n/locales/zh.js +6 -1
  102. package/dist/i18n/locales/zh.js.map +1 -1
  103. package/dist/i18n/review.d.ts +2 -2
  104. package/dist/i18n/review.js +11 -0
  105. package/dist/i18n/review.js.map +1 -1
  106. package/dist/index.d.ts +6 -6
  107. package/dist/index.js +9 -1
  108. package/dist/index.js.map +1 -1
  109. package/dist/lib/strip-fade.d.ts +84 -5
  110. package/dist/lib/strip-fade.js +109 -11
  111. package/dist/lib/strip-fade.js.map +1 -1
  112. package/dist/lib/translation-review.d.ts +45 -1
  113. package/dist/lib/translation-review.js +38 -1
  114. package/dist/lib/translation-review.js.map +1 -1
  115. package/dist/rhf/fields.d.ts +2 -2
  116. package/dist/rhf/form.d.ts +2 -2
  117. package/dist/rhf.d.ts +2 -2
  118. package/dist/shell/app-shell.d.ts +2 -2
  119. package/dist/shell/top-bar-brand.d.ts +2 -2
  120. package/dist/shell.d.ts +2 -2
  121. package/dist/wizard/stepper-nav.d.ts +2 -2
  122. package/dist/wizard.d.ts +2 -2
  123. package/package.json +1 -1
  124. package/src/components/column-mapper.tsx +120 -113
  125. package/src/components/data-table-pagination.tsx +10 -1
  126. package/src/components/data-table.tsx +34 -1
  127. package/src/components/swipeable-row.tsx +1 -1
  128. package/src/components/table.tsx +39 -0
  129. package/src/components/tooltip.tsx +487 -104
  130. package/src/components/translation-review-editor.tsx +21 -1
  131. package/src/components/translation-review-labels.ts +17 -0
  132. package/src/components/translation-review.tsx +657 -109
  133. package/src/components/ui.tsx +104 -4
  134. package/src/feedback/feedback-dialog.tsx +21 -0
  135. package/src/feedback/feedback-inbox.tsx +19 -2
  136. package/src/hooks/use-body-scroll-lock.ts +25 -0
  137. package/src/hooks/use-search-param-state.ts +283 -41
  138. package/src/i18n/german.ts +10 -0
  139. package/src/i18n/locales/en.ts +7 -0
  140. package/src/i18n/locales/es.ts +9 -0
  141. package/src/i18n/locales/fr.ts +9 -0
  142. package/src/i18n/locales/hu.ts +11 -0
  143. package/src/i18n/locales/it.ts +9 -0
  144. package/src/i18n/locales/zh.ts +8 -0
  145. package/src/i18n/review.ts +11 -0
  146. package/src/index.ts +12 -0
  147. package/src/lib/strip-fade.ts +220 -15
  148. package/src/lib/translation-review.ts +85 -0
@@ -732,6 +732,7 @@ export function germanLabels(numberLocale: string): UiKitLabels {
732
732
  roleOf: (column) => `Was enthält die Spalte „${column}“?`,
733
733
  ignore: "Ignorieren",
734
734
  requiredRole: (role) => `${role} (erforderlich)`,
735
+ requiredRoleShort: (role) => `${role} *`,
735
736
  previewOf: (shown, total) =>
736
737
  shown === 1
737
738
  ? `Die erste von ${n(total)} Zeilen`
@@ -788,6 +789,15 @@ export function germanLabels(numberLocale: string): UiKitLabels {
788
789
  scope: (areas) => `Ihre Prüfung ist beschränkt auf: ${areas}.`,
789
790
  exportCorrections: (count) => `Korrekturen exportieren (${n(count)})`,
790
791
  failed: "Das hat nicht geklappt. Bitte erneut versuchen.",
792
+ approvedToast: (count) => (count === 1 ? "Text bestätigt" : `${n(count)} Texte bestätigt`),
793
+ groupCount: (unreviewed, total) => `${n(unreviewed)} ungeprüft / ${n(total)}`,
794
+ approveGroup: (count) => `Ungeprüfte bestätigen (${n(count)})`,
795
+ // The group's name quoted, as `columnMapper.roleOf`'s column: it is often a key
796
+ // ("billing"), which unquoted would read as part of the sentence.
797
+ confirmGroup: (count, group) =>
798
+ count === 1
799
+ ? `Den ungeprüften Text in „${group}“ bestätigen?`
800
+ : `Alle ${n(count)} ungeprüften Texte in „${group}“ bestätigen – auch die nicht angezeigten?`,
791
801
  },
792
802
  };
793
803
  }
@@ -126,6 +126,13 @@ export function uiKitLabelsEn(numberLocale = "en-US"): UiKitLabels {
126
126
  progress: (approved, total) => `${n(approved)} of ${n(total)} approved`,
127
127
  localeProgress: (approved, total) => `${n(approved)}/${n(total)}`,
128
128
  exportCorrections: (count) => `Export corrections (${n(count)})`,
129
+ approvedToast: (count) => (count === 1 ? "String approved" : `${n(count)} strings approved`),
130
+ groupCount: (unreviewed, total) => `${n(unreviewed)} unreviewed / ${n(total)}`,
131
+ approveGroup: (count) => `Approve unreviewed (${n(count)})`,
132
+ confirmGroup: (count, group) =>
133
+ count === 1
134
+ ? `Approve the unreviewed string in ${group}?`
135
+ : `Approve all ${n(count)} unreviewed strings in ${group}, including those not on screen?`,
129
136
  },
130
137
  characterCount: {
131
138
  ...d.characterCount,
@@ -712,6 +712,7 @@ export function uiKitLabelsEs(numberLocale = "es-ES"): UiKitLabels {
712
712
  roleOf: (column) => `¿Qué contiene la columna «${column}»?`,
713
713
  ignore: "Ignorar",
714
714
  requiredRole: (role) => `${role} (obligatorio)`,
715
+ requiredRoleShort: (role) => `${role} *`,
715
716
  previewOf: (shown, total) =>
716
717
  shown === 1
717
718
  ? `La primera de ${n(total)} filas`
@@ -768,6 +769,14 @@ export function uiKitLabelsEs(numberLocale = "es-ES"): UiKitLabels {
768
769
  scope: (areas) => `Su revisión se limita a: ${areas}.`,
769
770
  exportCorrections: (count) => `Exportar correcciones (${n(count)})`,
770
771
  failed: "No ha funcionado. Inténtelo de nuevo.",
772
+ approvedToast: (count) => (count === 1 ? "Texto aprobado" : `${n(count)} textos aprobados`),
773
+ // "sin revisar" does not agree with the count, so one form serves both.
774
+ groupCount: (unreviewed, total) => `${n(unreviewed)} sin revisar / ${n(total)}`,
775
+ approveGroup: (count) => `Aprobar los sin revisar (${n(count)})`,
776
+ confirmGroup: (count, group) =>
777
+ count === 1
778
+ ? `¿Aprobar el texto sin revisar de «${group}»?`
779
+ : `¿Aprobar los ${n(count)} textos sin revisar de «${group}», incluidos los que no están en pantalla?`,
771
780
  },
772
781
  };
773
782
  }
@@ -730,6 +730,7 @@ export function uiKitLabelsFr(numberLocale = "fr-FR"): UiKitLabels {
730
730
  roleOf: (column) => `Que contient la colonne «\u202f${column}\u202f»\u202f?`,
731
731
  ignore: "Ignorer",
732
732
  requiredRole: (role) => `${role} (obligatoire)`,
733
+ requiredRoleShort: (role) => `${role} *`,
733
734
  previewOf: (shown, total) =>
734
735
  shown < 2
735
736
  ? `La première ligne sur ${n(total)}`
@@ -785,6 +786,14 @@ export function uiKitLabelsFr(numberLocale = "fr-FR"): UiKitLabels {
785
786
  scope: (areas) => `Votre relecture se limite à\u00a0: ${areas}.`,
786
787
  exportCorrections: (count) => `Exporter les corrections (${n(count)})`,
787
788
  failed: "Cela n’a pas fonctionné. Veuillez réessayer.",
789
+ approvedToast: (count) => (count < 2 ? "Texte approuvé" : `${n(count)} textes approuvés`),
790
+ groupCount: (unreviewed, total) =>
791
+ `${n(unreviewed)} non ${plural(unreviewed, "relu", "relus")} / ${n(total)}`,
792
+ approveGroup: (count) => `Approuver les non relus (${n(count)})`,
793
+ confirmGroup: (count, group) =>
794
+ count < 2
795
+ ? `Approuver le texte non relu de «\u202f${group}\u202f»\u202f?`
796
+ : `Approuver les ${n(count)} textes non relus de «\u202f${group}\u202f», y compris ceux qui ne sont pas à l’écran\u202f?`,
788
797
  },
789
798
  };
790
799
  }
@@ -717,6 +717,7 @@ export function uiKitLabelsHu(numberLocale = "hu-HU"): UiKitLabels {
717
717
  roleOf: (column) => `Mit tartalmaz ez az oszlop: „${column}”?`,
718
718
  ignore: "Kihagyás",
719
719
  requiredRole: (role) => `${role} (kötelező)`,
720
+ requiredRoleShort: (role) => `${role} *`,
720
721
  // "(összesen N)", as `dataTable.pageChanged`: both numerals stay bare (convention 3).
721
722
  previewOf: (shown, total) => `Az első ${n(shown)} sor (összesen ${n(total)})`,
722
723
  // `roles` arrives joined with "vagy" ("Tartozik vagy Követel").
@@ -771,6 +772,16 @@ export function uiKitLabelsHu(numberLocale = "hu-HU"): UiKitLabels {
771
772
  scope: (areas) => `Az Ön ellenőrzése a következőkre korlátozódik: ${areas}.`,
772
773
  exportCorrections: (count) => `Javítások exportálása (${n(count)})`,
773
774
  failed: "Ez nem sikerült. Kérjük, próbálja újra.",
775
+ // No plural after a numeral (convention 2).
776
+ approvedToast: (count) => (count === 1 ? "Szöveg jóváhagyva" : `${n(count)} szöveg jóváhagyva`),
777
+ groupCount: (unreviewed, total) => `${n(unreviewed)} ellenőrizetlen / ${n(total)}`,
778
+ approveGroup: (count) => `Ellenőrizetlenek jóváhagyása (${n(count)})`,
779
+ // Opens with the quoted name, as `filePicker`'s refusals: no case ending ("-ban/-ben")
780
+ // has to agree with it. "a(z)" before the count, as `filePicker.rejectedPick`.
781
+ confirmGroup: (count, group) =>
782
+ count === 1
783
+ ? `„${group}”: jóváhagyja az ellenőrizetlen szöveget?`
784
+ : `„${group}”: jóváhagyja mind a(z) ${n(count)} ellenőrizetlen szöveget, a képernyőn nem láthatókat is?`,
774
785
  },
775
786
  };
776
787
  }
@@ -716,6 +716,7 @@ export function uiKitLabelsIt(numberLocale = "it-IT"): UiKitLabels {
716
716
  roleOf: (column) => `Che cosa contiene la colonna «${column}»?`,
717
717
  ignore: "Ignora",
718
718
  requiredRole: (role) => `${role} (obbligatorio)`,
719
+ requiredRoleShort: (role) => `${role} *`,
719
720
  previewOf: (shown, total) =>
720
721
  shown === 1
721
722
  ? `La prima di ${n(total)} righe`
@@ -772,6 +773,14 @@ export function uiKitLabelsIt(numberLocale = "it-IT"): UiKitLabels {
772
773
  scope: (areas) => `La Sua revisione è limitata a: ${areas}.`,
773
774
  exportCorrections: (count) => `Esporta le correzioni (${n(count)})`,
774
775
  failed: "Non ha funzionato. Riprovi.",
776
+ approvedToast: (count) => (count === 1 ? "Testo approvato" : `${n(count)} testi approvati`),
777
+ groupCount: (unreviewed, total) =>
778
+ `${n(unreviewed)} ${plural(unreviewed, "non rivisto", "non rivisti")} / ${n(total)}`,
779
+ approveGroup: (count) => `Approva i non rivisti (${n(count)})`,
780
+ confirmGroup: (count, group) =>
781
+ count === 1
782
+ ? `Approvare il testo non rivisto in «${group}»?`
783
+ : `Approvare tutti i ${n(count)} testi non rivisti in «${group}», compresi quelli non visibili sullo schermo?`,
775
784
  },
776
785
  };
777
786
  }
@@ -694,6 +694,7 @@ export function uiKitLabelsZh(numberLocale = "zh-CN"): UiKitLabels {
694
694
  roleOf: (column) => `“${column}”列的内容是什么?`,
695
695
  ignore: "忽略",
696
696
  requiredRole: (role) => `${role}(必填)`,
697
+ requiredRoleShort: (role) => `${role} *`,
697
698
  previewOf: (shown, total) => `显示前 ${n(shown)} 行,共 ${n(total)} 行`,
698
699
  // `roles` arrives joined with "或" ("借方或贷方").
699
700
  oneOf: (roles) => `${roles}(任选其一)`,
@@ -747,6 +748,13 @@ export function uiKitLabelsZh(numberLocale = "zh-CN"): UiKitLabels {
747
748
  scope: (areas) => `审校范围仅限于:${areas}。`,
748
749
  exportCorrections: (count) => `导出更正(${n(count)})`,
749
750
  failed: "操作未成功,请重试。",
751
+ approvedToast: (count) => (count === 1 ? "文本已通过" : `已通过 ${n(count)} 条文本`),
752
+ groupCount: (unreviewed, total) => `未审校 ${n(unreviewed)} / ${n(total)}`,
753
+ approveGroup: (count) => `通过未审校项(${n(count)})`,
754
+ confirmGroup: (count, group) =>
755
+ count === 1
756
+ ? `通过“${group}”中的未审校文本?`
757
+ : `通过“${group}”中全部 ${n(count)} 条未审校文本(包括未显示在屏幕上的)?`,
750
758
  },
751
759
  };
752
760
  }
@@ -193,6 +193,16 @@ export const KIT_LABEL_SAMPLES: Readonly<Record<string, readonly (readonly unkno
193
193
  "translationReview.lastFlagged": [["{{name}}", "{{date}}"]],
194
194
  "translationReview.scope": [["{{areas}}"]],
195
195
  "translationReview.exportCorrections": [[0], [1], [3]],
196
+ "translationReview.approvedToast": one,
197
+ "translationReview.groupCount": [
198
+ [1, 12],
199
+ [3, 12],
200
+ ],
201
+ "translationReview.approveGroup": one,
202
+ "translationReview.confirmGroup": [
203
+ [1, "{{group}}"],
204
+ [3, "{{group}}"],
205
+ ],
196
206
  "characterCount.count": [[12, 80]],
197
207
  "characterCount.remaining": one,
198
208
  "inlineEdit.edit": [["{{label}}"]],
@@ -210,6 +220,7 @@ export const KIT_LABEL_SAMPLES: Readonly<Record<string, readonly (readonly unkno
210
220
  "columnMapper.columnN": [[3]],
211
221
  "columnMapper.roleOf": [["{{column}}"]],
212
222
  "columnMapper.requiredRole": [["{{role}}"]],
223
+ "columnMapper.requiredRoleShort": [["{{role}}"]],
213
224
  "columnMapper.previewOf": [
214
225
  [1, 12],
215
226
  [10, 250],
package/src/index.ts CHANGED
@@ -47,6 +47,9 @@ export type { DebouncedCallbackOptions, DebouncedFunction } from "./hooks/use-de
47
47
  // URL (Modal's `urlParam`) — kastlan's and keksdose's use-search-param-state, in the kit.
48
48
  export { useSearchParamState, useTabParam, useDialogParam } from "./hooks/use-search-param-state";
49
49
  export type { SearchParamStateOptions, DialogParam } from "./hooks/use-search-param-state";
50
+ // 0.25: several keys, one navigation (keksdose live #378 — four setters in a row clobbered).
51
+ export { useSearchParamsState } from "./hooks/use-search-param-state";
52
+ export type { SearchParamField, SearchParamFields, SearchParamsUpdate } from "./hooks/use-search-param-state";
50
53
  // Copy that reports whether it worked — keksdose's copy buttons said "Copied" when it had not.
51
54
  export { useCopyToClipboard, copyToClipboard } from "./hooks/use-copy-to-clipboard";
52
55
  export type { CopyState, UseCopyToClipboardOptions, UseCopyToClipboardReturn } from "./hooks/use-copy-to-clipboard";
@@ -388,6 +391,10 @@ export {
388
391
  mergeReviews,
389
392
  dropReviews,
390
393
  translationCorrections,
394
+ // 0.25: grouping and the approvals' Undo (keksdose live #377).
395
+ groupTranslationRows,
396
+ unreviewedRows,
397
+ reviewUndo,
391
398
  } from "./lib/translation-review";
392
399
  export type {
393
400
  TranslationVerdict,
@@ -402,6 +409,8 @@ export type {
402
409
  TranslationSummary,
403
410
  TranslationReviewFilter,
404
411
  TranslationCorrection,
412
+ TranslationRowGroup,
413
+ TranslationReviewUndo,
405
414
  } from "./lib/translation-review";
406
415
  export {
407
416
  TranslationReviewPanel,
@@ -422,6 +431,9 @@ export type {
422
431
  TranslationLocaleTabsProps,
423
432
  TranslationExportButtonProps,
424
433
  TranslationReviewLabels,
434
+ TranslationReviewSaveInfo,
435
+ TranslationReviewOrigin,
436
+ TranslationReviewGroupBy,
425
437
  } from "./components/translation-review";
426
438
  // The legal pages' shell (0.19, H10): Imprint, Privacy Policy, Terms in every app.
427
439
  export { LegalLayout, LegalSection, LegalLinks, DEFAULT_LEGAL_LABELS } from "./components/legal";
@@ -1,12 +1,129 @@
1
- import { useLayoutEffect, useState, type RefObject } from "react";
1
+ import { useCallback, useLayoutEffect, useState } from "react";
2
+ import type { CSSProperties, FocusEvent, RefObject } from "react";
2
3
 
3
4
  // How far the fade at a scrolled strip's cut edge reaches, and how much room a tab
4
5
  // brought into view keeps from that edge so the fade never lies on top of it.
5
6
  export const TAB_FADE_PX = 24;
6
7
 
7
8
  /**
8
- * Shared by Tabs' strip, AppShell's scrolling phone sub-nav and (0.24) ColumnRoleTable's
9
- * preview; internal, not part of the package's surface.
9
+ * What {@link useStripFade} can be asked to do beyond the strip it was written for.
10
+ */
11
+ export interface StripFadeOptions {
12
+ /**
13
+ * Keep the strip's own SCROLLBARS out of the fade (0.25, for the kit Table's and
14
+ * DataTable's `edgeFade`). A `mask-image` masks everything the element paints, and
15
+ * a classic scrollbar is painted by the element: on a desktop with always-visible
16
+ * scrollbars, a DataTable's vertical scrollbar sits at the inline end, inside the
17
+ * very band that fades whenever columns are hidden that way — it all but vanished,
18
+ * and the horizontal one lost its ends. With this set, the mask is opaque over
19
+ * each scrollbar (measured: `offsetWidth − clientWidth − borders`, its side read
20
+ * off `clientLeft`, which is where an RTL scrollbar on the left shows up) and the
21
+ * fade runs inside it, from the content's own edge. Where scrollbars take no room
22
+ * — phones, overlay scrollbars, `scrollbar-width: none` — nothing changes: the
23
+ * mask is the plain one. Off by default, so Tabs and AppShell paint exactly as
24
+ * they did.
25
+ */
26
+ spareScrollbars?: boolean;
27
+ }
28
+
29
+ /** The room the classic scrollbars of a strip take, and the borders beside them. */
30
+ export interface StripScrollbars {
31
+ /** Vertical scrollbar on the left (RTL in Chromium and Firefox), in px. */
32
+ left: number;
33
+ /** Vertical scrollbar on the right, in px. */
34
+ right: number;
35
+ /** Horizontal scrollbar, in px. */
36
+ bottom: number;
37
+ /** The border widths outside each of them, in px — the fade still covers a border. */
38
+ borderLeft: number;
39
+ borderRight: number;
40
+ borderBottom: number;
41
+ }
42
+
43
+ const NO_SCROLLBARS: StripScrollbars = {
44
+ left: 0,
45
+ right: 0,
46
+ bottom: 0,
47
+ borderLeft: 0,
48
+ borderRight: 0,
49
+ borderBottom: 0,
50
+ };
51
+
52
+ // A scrollbar is never this thin; anything under it is sub-pixel rounding between
53
+ // `offsetWidth` and `clientWidth`, not a scrollbar.
54
+ const MIN_SCROLLBAR_PX = 2;
55
+
56
+ function measureScrollbars(el: HTMLElement, style: CSSStyleDeclaration): StripScrollbars {
57
+ const px = (value: string) => parseFloat(value) || 0;
58
+ const borderLeft = px(style.borderLeftWidth);
59
+ const borderRight = px(style.borderRightWidth);
60
+ const borderTop = px(style.borderTopWidth);
61
+ const borderBottom = px(style.borderBottomWidth);
62
+ const vertical = el.offsetWidth - el.clientWidth - borderLeft - borderRight;
63
+ const horizontal = el.offsetHeight - el.clientHeight - borderTop - borderBottom;
64
+ const onLeft = el.clientLeft - borderLeft >= MIN_SCROLLBAR_PX;
65
+ const v = vertical >= MIN_SCROLLBAR_PX ? Math.round(vertical) : 0;
66
+ return {
67
+ left: onLeft ? v : 0,
68
+ right: onLeft ? 0 : v,
69
+ bottom: horizontal >= MIN_SCROLLBAR_PX ? Math.round(horizontal) : 0,
70
+ borderLeft,
71
+ borderRight,
72
+ borderBottom,
73
+ };
74
+ }
75
+
76
+ const sameScrollbars = (a: StripScrollbars, b: StripScrollbars) =>
77
+ a.left === b.left &&
78
+ a.right === b.right &&
79
+ a.bottom === b.bottom &&
80
+ a.borderLeft === b.borderLeft &&
81
+ a.borderRight === b.borderRight &&
82
+ a.borderBottom === b.borderBottom;
83
+
84
+ /**
85
+ * The `mask-image` for a strip whose `left` and/or `right` (physical) edge has
86
+ * content behind it, or `undefined` when neither has. Pure, for {@link useStripFade}
87
+ * and its tests; `scrollbars` (see {@link StripFadeOptions.spareScrollbars}) keeps
88
+ * those bands opaque and starts the fade at the content's edge instead of the box's.
89
+ */
90
+ export function stripFadeMask(
91
+ edges: { left: boolean; right: boolean },
92
+ scrollbars: StripScrollbars = NO_SCROLLBARS,
93
+ ): string | undefined {
94
+ if (!edges.left && !edges.right) return undefined;
95
+ const F = TAB_FADE_PX;
96
+ const sb = scrollbars;
97
+ // The plain mask, as Tabs and AppShell have always drawn it.
98
+ let l = edges.left ? `transparent, #000 ${F}px` : "#000";
99
+ let r = edges.right ? `#000 calc(100% - ${F}px), transparent` : "#000";
100
+ const fromRight = (px: number) => (px === 0 ? "100%" : `calc(100% - ${px}px)`);
101
+ // A vertical scrollbar on a FADED side: the border outside it fades with the rest
102
+ // of that edge, the scrollbar itself stays whole, and the fade starts inside it.
103
+ if (edges.left && sb.left > 0) {
104
+ const inner = sb.borderLeft + sb.left;
105
+ const border = sb.borderLeft > 0 ? `transparent ${sb.borderLeft}px, ` : "";
106
+ l = `${border}#000 ${sb.borderLeft}px, #000 ${inner}px, transparent ${inner}px, #000 ${inner + F}px`;
107
+ }
108
+ if (edges.right && sb.right > 0) {
109
+ const inner = sb.borderRight + sb.right;
110
+ const border =
111
+ sb.borderRight > 0 ? `, #000 ${fromRight(sb.borderRight)}, transparent ${fromRight(sb.borderRight)}` : "";
112
+ r = `#000 ${fromRight(inner + F)}, transparent ${fromRight(inner)}, #000 ${fromRight(inner)}${border}`;
113
+ }
114
+ const across = `linear-gradient(to right, ${l}, ${r})`;
115
+ if (sb.bottom <= 0) return across;
116
+ // The horizontal scrollbar, as a second layer: layers ADD, so this band is opaque
117
+ // over its whole width whatever the first layer says there.
118
+ const top = sb.borderBottom + sb.bottom;
119
+ const border = sb.borderBottom > 0 ? `transparent ${sb.borderBottom}px, ` : "";
120
+ return `${across}, linear-gradient(to top, ${border}#000 ${sb.borderBottom}px, #000 ${top}px, transparent ${top}px)`;
121
+ }
122
+
123
+ /**
124
+ * Shared by Tabs' strip, AppShell's scrolling phone sub-nav, (0.24) ColumnRoleTable's
125
+ * preview and (0.25) Table's and DataTable's `edgeFade`; internal, not part of the
126
+ * package's surface.
10
127
  *
11
128
  * Which ends of a sideways-scrolling strip have tabs cut off behind them, as the
12
129
  * `mask-image` that fades those ends out — or `undefined` while everything fits.
@@ -22,21 +139,27 @@ export const TAB_FADE_PX = 24;
22
139
  */
23
140
  export function useStripFade(
24
141
  ref: RefObject<HTMLElement | null>,
142
+ options?: StripFadeOptions,
25
143
  ): { mask: string | undefined; overflow: "start" | "end" | "both" | undefined } {
26
- const [edges, setEdges] = useState({ left: false, right: false, rtl: false });
144
+ const spare = options?.spareScrollbars === true;
145
+ const [edges, setEdges] = useState({ left: false, right: false, rtl: false, scrollbars: NO_SCROLLBARS });
27
146
  useLayoutEffect(() => {
28
147
  const el = ref.current;
29
148
  if (!el) return;
30
149
  const measure = () => {
31
150
  const hidden = el.scrollWidth - el.clientWidth;
32
151
  const scrolled = Math.abs(el.scrollLeft);
33
- const rtl = getComputedStyle(el).direction === "rtl";
152
+ const style = getComputedStyle(el);
153
+ const rtl = style.direction === "rtl";
34
154
  const atStart = scrolled > 1;
35
155
  const atEnd = hidden - scrolled > 1;
36
156
  const left = rtl ? atEnd : atStart;
37
157
  const right = rtl ? atStart : atEnd;
158
+ const scrollbars = spare && (left || right) ? measureScrollbars(el, style) : NO_SCROLLBARS;
38
159
  setEdges((prev) =>
39
- prev.left === left && prev.right === right && prev.rtl === rtl ? prev : { left, right, rtl },
160
+ prev.left === left && prev.right === right && prev.rtl === rtl && sameScrollbars(prev.scrollbars, scrollbars)
161
+ ? prev
162
+ : { left, right, rtl, scrollbars },
40
163
  );
41
164
  };
42
165
  measure();
@@ -51,14 +174,13 @@ export function useStripFade(
51
174
  observer?.disconnect();
52
175
  };
53
176
  });
54
- if (!edges.left && !edges.right) return { mask: undefined, overflow: undefined };
55
- const l = edges.left ? `transparent, #000 ${TAB_FADE_PX}px` : "#000";
56
- const r = edges.right ? `#000 calc(100% - ${TAB_FADE_PX}px), transparent` : "#000";
177
+ const mask = stripFadeMask(edges, edges.scrollbars);
178
+ if (mask === undefined) return { mask: undefined, overflow: undefined };
57
179
  const rtl = edges.rtl;
58
180
  const start = rtl ? edges.right : edges.left;
59
181
  const end = rtl ? edges.left : edges.right;
60
182
  return {
61
- mask: `linear-gradient(to right, ${l}, ${r})`,
183
+ mask,
62
184
  overflow: start && end ? "both" : start ? "start" : "end",
63
185
  };
64
186
  }
@@ -67,16 +189,99 @@ export function useStripFade(
67
189
  * Scroll `item` into view inside `strip` — the strip only (`scrollBy` on it, never
68
190
  * `scrollIntoView`, which would scroll the page too) — far enough to clear the fade.
69
191
  * A no-op when the strip fits or the item is already clear of both edges.
192
+ *
193
+ * The edges are the strip's CONTENT edges (0.25: `clientLeft` + `clientWidth`), not
194
+ * its box's: a vertical scrollbar or a side border takes room the item can never be
195
+ * scrolled into, and {@link StripFadeOptions.spareScrollbars} starts the fade inside
196
+ * the scrollbar. A Tabs strip has neither, so its edges are where they were.
70
197
  */
71
198
  export function scrollIntoStrip(strip: HTMLElement, item: HTMLElement): void {
72
199
  if (strip.scrollWidth <= strip.clientWidth) return;
73
- const s = strip.getBoundingClientRect();
200
+ const box = strip.getBoundingClientRect();
201
+ const left = box.left + strip.clientLeft;
202
+ const right = left + strip.clientWidth;
74
203
  const t = item.getBoundingClientRect();
75
204
  const delta =
76
- t.left < s.left + TAB_FADE_PX
77
- ? t.left - s.left - TAB_FADE_PX
78
- : t.right > s.right - TAB_FADE_PX
79
- ? t.right - s.right + TAB_FADE_PX
205
+ t.left < left + TAB_FADE_PX
206
+ ? t.left - left - TAB_FADE_PX
207
+ : t.right > right - TAB_FADE_PX
208
+ ? t.right - right + TAB_FADE_PX
80
209
  : 0;
81
210
  if (delta !== 0) strip.scrollBy({ left: delta });
82
211
  }
212
+
213
+ /**
214
+ * Focus landed on `focused` inside a faded table's scroller (0.25, Table and
215
+ * DataTable `edgeFade`): bring it clear of the fade. The browser's own focus scroll
216
+ * stops at the scroller's edge — under the fade, where a focused control is a few
217
+ * faint pixels. Its whole CELL is brought clear when the cell fits between the two
218
+ * fades (a select that fills its header, as ColumnRoleTable's do, then keeps the
219
+ * cell's padding round it too); a cell wider than that — a long note with a link at
220
+ * its end — would be brought in by its start and leave the link out, so then the
221
+ * focused element itself is what is scrolled clear.
222
+ */
223
+ export function scrollFocusIntoStrip(strip: HTMLElement, focused: HTMLElement): void {
224
+ if (focused === strip || !strip.contains(focused)) return;
225
+ const cell = focused.closest<HTMLElement>("td, th");
226
+ const rect = cell && strip.contains(cell) ? cell.getBoundingClientRect() : null;
227
+ const fits = rect !== null && rect.right - rect.left <= strip.clientWidth - 2 * TAB_FADE_PX;
228
+ scrollIntoStrip(strip, fits && cell ? cell : focused);
229
+ }
230
+
231
+ /** `edgeFade` off: {@link useStripFade} gets no element and measures nothing. */
232
+ const NO_ELEMENT: RefObject<HTMLElement | null> = { current: null };
233
+ const SPARE_SCROLLBARS: StripFadeOptions = { spareScrollbars: true };
234
+
235
+ /** `:focus-visible`, or `false` where the engine cannot say. */
236
+ function focusVisible(el: Element): boolean {
237
+ try {
238
+ return el.matches(":focus-visible");
239
+ } catch {
240
+ return false;
241
+ }
242
+ }
243
+
244
+ /** What {@link useEdgeFade} puts on the scroller; every field `undefined` while off. */
245
+ export interface EdgeFadeProps {
246
+ "data-overflow": "start" | "end" | "both" | undefined;
247
+ style: CSSProperties | undefined;
248
+ onFocus: ((event: FocusEvent<HTMLElement>) => void) | undefined;
249
+ onBlur: ((event: FocusEvent<HTMLElement>) => void) | undefined;
250
+ }
251
+
252
+ /**
253
+ * Table's and DataTable's `edgeFade` on their scroller (0.25; internal, like the rest
254
+ * of this file): the measured fade with the scroller's own scrollbars spared, its
255
+ * `data-overflow`, focus inside brought clear of it ({@link scrollFocusIntoStrip}),
256
+ * and — on a scroller that is itself a tab stop, as Table's is while it overflows —
257
+ * the fade lifted while the scroller shows the keyboard's focus outline: an inset
258
+ * outline lies exactly in the faded band, and its sides would fade out with the
259
+ * columns. Inert while `on` is false: no measuring, no listeners, no attributes.
260
+ *
261
+ * `style` is the mask alone; a caller with a style of its own spreads it over that.
262
+ */
263
+ export function useEdgeFade(scroller: RefObject<HTMLElement | null>, on: boolean): EdgeFadeProps {
264
+ const fade = useStripFade(on ? scroller : NO_ELEMENT, SPARE_SCROLLBARS);
265
+ const [selfFocused, setSelfFocused] = useState(false);
266
+ const onFocus = useCallback((event: FocusEvent<HTMLElement>) => {
267
+ const box = event.currentTarget;
268
+ if (event.target === box) {
269
+ // A click in the table's text focuses the scroller too (it is the nearest tab
270
+ // stop); only the keyboard's focus draws an outline worth lifting the fade for.
271
+ if (focusVisible(box)) setSelfFocused(true);
272
+ return;
273
+ }
274
+ if (event.target instanceof HTMLElement) scrollFocusIntoStrip(box, event.target);
275
+ }, []);
276
+ const onBlur = useCallback((event: FocusEvent<HTMLElement>) => {
277
+ if (event.target === event.currentTarget) setSelfFocused(false);
278
+ }, []);
279
+ if (!on) return { "data-overflow": undefined, style: undefined, onFocus: undefined, onBlur: undefined };
280
+ const mask = selfFocused ? undefined : fade.mask;
281
+ return {
282
+ "data-overflow": fade.overflow,
283
+ style: mask ? { maskImage: mask, WebkitMaskImage: mask } : undefined,
284
+ onFocus,
285
+ onBlur,
286
+ };
287
+ }
@@ -387,6 +387,49 @@ export function filterTranslationRows(
387
387
  );
388
388
  }
389
389
 
390
+ // ── Grouping ────────────────────────────────────────────────────────────────────
391
+
392
+ /** One group of rows — an area, a source — and how they stand. */
393
+ export interface TranslationRowGroup {
394
+ key: string;
395
+ rows: TranslationRow[];
396
+ summary: TranslationSummary;
397
+ }
398
+
399
+ /**
400
+ * Rows in groups, each group where its first row is — the reference's order, so the areas
401
+ * read the way the app's authors wrote the bundle — and each group's rows in their own
402
+ * order. keksdose live #377: a reviewer on a phone works area by area ("the budget
403
+ * strings, then the legal ones"), and a flat list of 3859 rows is not a way to see
404
+ * where an area stands.
405
+ *
406
+ * `groupOf` names a row's group: `(row) => row.namespace` for the areas,
407
+ * `(row) => row.source` for kastlan's screen texts and documents.
408
+ */
409
+ export function groupTranslationRows(
410
+ rows: readonly TranslationRow[],
411
+ groupOf: (row: TranslationRow) => string,
412
+ ): TranslationRowGroup[] {
413
+ const byKey = new Map<string, TranslationRow[]>();
414
+ for (const row of rows) {
415
+ const key = groupOf(row);
416
+ const list = byKey.get(key);
417
+ if (list) list.push(row);
418
+ else byKey.set(key, [row]);
419
+ }
420
+ return [...byKey].map(([key, list]) => ({ key, rows: list, summary: summariseRows(list) }));
421
+ }
422
+
423
+ /**
424
+ * The rows a group's "Approve the unreviewed" takes: the ones nobody has given a verdict
425
+ * and that have a text. Not `changed` — a string that moved since it was judged asks to
426
+ * be READ again, which a bulk action is the opposite of — and not `missing`, which has
427
+ * nothing to approve.
428
+ */
429
+ export function unreviewedRows(rows: readonly TranslationRow[]): TranslationRow[] {
430
+ return rows.filter((row) => row.status === "unreviewed" && row.text !== "");
431
+ }
432
+
390
433
  // ── Writing ─────────────────────────────────────────────────────────────────────
391
434
 
392
435
  /** What gets stored for a verdict: the wording it was given on, so a later change of the
@@ -408,6 +451,48 @@ export function reviewWrite(
408
451
  };
409
452
  }
410
453
 
454
+ /** What takes a set of verdicts back: see {@link reviewUndo}. */
455
+ export interface TranslationReviewUndo {
456
+ /** The verdicts the rows had before, to store again — each on the wording it was
457
+ * given on, so a row that was `changed` reads `changed` again. */
458
+ writes: TranslationReviewWrite[];
459
+ /** The rows that had no verdict before: back to unreviewed. */
460
+ clears: TranslationReviewKey[];
461
+ }
462
+
463
+ /**
464
+ * How to take back what was just said about `rows` — the rows AS THEY WERE before the
465
+ * write, with the verdicts they carried then. The panel's Undo toast (0.25, keksdose live
466
+ * #377) runs it: a swipe on a phone approves in one movement, and a movement can be a
467
+ * mistake.
468
+ *
469
+ * A row that had a verdict gets it stored again (its note, its suggestion, the wording it
470
+ * was judged on); one that had none is cleared. The server stamps the restored verdict
471
+ * with the person undoing and the time, as it stamps every write — the contract has no
472
+ * way to write a verdict in somebody else's name, and should not.
473
+ */
474
+ export function reviewUndo(rows: readonly TranslationRow[]): TranslationReviewUndo {
475
+ const writes: TranslationReviewWrite[] = [];
476
+ const clears: TranslationReviewKey[] = [];
477
+ for (const row of rows) {
478
+ const before = row.review;
479
+ if (!before) {
480
+ clears.push({ locale: row.locale, key: row.key });
481
+ continue;
482
+ }
483
+ writes.push({
484
+ locale: row.locale,
485
+ key: row.key,
486
+ text: before.text,
487
+ referenceText: before.referenceText,
488
+ verdict: before.verdict,
489
+ note: before.note,
490
+ suggestion: before.suggestion,
491
+ });
492
+ }
493
+ return { writes, clears };
494
+ }
495
+
411
496
  /** The app's cached verdict list with `written` in place of what was said before — a
412
497
  * save answers with the rows it wrote, so a page patches its copy instead of
413
498
  * downloading every verdict again after each click. Any spelling with `locale` and