@eifi1/ui-kit 0.49.0 → 0.50.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.
- package/README.md +12 -8
- package/dist/admin/user-roster.d.ts +16 -10
- package/dist/admin/user-roster.js +7 -3
- package/dist/admin/user-roster.js.map +1 -1
- package/dist/auth/verify-email.d.ts +9 -1
- package/dist/auth/verify-email.js +3 -1
- package/dist/auth/verify-email.js.map +1 -1
- package/dist/components/amount-input.d.ts +2 -1
- package/dist/components/amount-input.js +3 -2
- package/dist/components/amount-input.js.map +1 -1
- package/dist/components/autocomplete.js +14 -0
- package/dist/components/autocomplete.js.map +1 -1
- package/dist/components/combobox-core.js +1 -0
- package/dist/components/combobox-core.js.map +1 -1
- package/dist/components/combobox-suggestions.js +1 -0
- package/dist/components/combobox-suggestions.js.map +1 -1
- package/dist/components/combobox.js +3 -1
- package/dist/components/combobox.js.map +1 -1
- package/dist/components/currency-select.d.ts +28 -2
- package/dist/components/currency-select.js +56 -10
- package/dist/components/currency-select.js.map +1 -1
- package/dist/components/data-table-mobile.d.ts +11 -2
- package/dist/components/data-table-mobile.js +62 -8
- package/dist/components/data-table-mobile.js.map +1 -1
- package/dist/components/data-table-row.d.ts +1 -1
- package/dist/components/data-table-row.js.map +1 -1
- package/dist/components/data-table.js +3 -1
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/dropdown.js +1 -0
- package/dist/components/dropdown.js.map +1 -1
- package/dist/components/full-bleed-dialog.js +1 -0
- package/dist/components/full-bleed-dialog.js.map +1 -1
- package/dist/components/inline-entity-combobox.js +12 -0
- package/dist/components/inline-entity-combobox.js.map +1 -1
- package/dist/components/modal.js +1 -0
- package/dist/components/modal.js.map +1 -1
- package/dist/components/number-input.d.ts +2 -1
- package/dist/components/number-input.js +6 -2
- package/dist/components/number-input.js.map +1 -1
- package/dist/components/numpad-sheet.js +2 -0
- package/dist/components/numpad-sheet.js.map +1 -1
- package/dist/components/picker-sheet.js +2 -0
- package/dist/components/picker-sheet.js.map +1 -1
- package/dist/components/popover.js +1 -0
- package/dist/components/popover.js.map +1 -1
- package/dist/eslint/index.js +1 -1
- package/dist/i18n/defaults.js +2 -0
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/german.js +14 -1
- package/dist/i18n/german.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +3 -0
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/locales/en.js +16 -1
- package/dist/i18n/locales/en.js.map +3 -3
- package/dist/i18n/locales/es.js +14 -1
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.js +14 -1
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.js +14 -1
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.js +14 -1
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.js +14 -1
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/i18n/review.js +1 -0
- package/dist/i18n/review.js.map +1 -1
- package/dist/lib/data-attributes.d.ts +11 -0
- package/dist/lib/data-attributes.js +10 -0
- package/dist/lib/data-attributes.js.map +1 -0
- package/dist/lib/format.d.ts +1 -0
- package/dist/lib/format.js +2 -0
- package/dist/lib/format.js.map +1 -1
- package/dist/lib/minus.d.ts +10 -0
- package/dist/lib/minus.js.map +1 -1
- package/dist/lib/quiet-focus.d.ts +4 -0
- package/dist/lib/quiet-focus.js +19 -0
- package/dist/lib/quiet-focus.js.map +1 -0
- package/dist/testing/index.d.ts +1 -0
- package/dist/testing/index.js +4 -0
- package/dist/testing/index.js.map +1 -1
- package/dist/testing/react.d.ts +25 -0
- package/dist/testing/react.js +19 -0
- package/dist/testing/react.js.map +1 -0
- package/dist/testing/tour.d.ts +31 -0
- package/dist/testing/tour.js +12 -0
- package/dist/testing/tour.js.map +1 -0
- package/dist/tour/tour-dock.d.ts +42 -0
- package/dist/tour/tour-dock.js +80 -0
- package/dist/tour/tour-dock.js.map +1 -0
- package/dist/tour/tour-engine.d.ts +12 -0
- package/dist/tour/tour-engine.js +31 -0
- package/dist/tour/tour-engine.js.map +1 -0
- package/dist/tour/tour-gates.d.ts +71 -0
- package/dist/tour/tour-gates.js +114 -0
- package/dist/tour/tour-gates.js.map +1 -0
- package/dist/tour/tour-guide-button.d.ts +27 -0
- package/dist/tour/tour-guide-button.js +45 -0
- package/dist/tour/tour-guide-button.js.map +1 -0
- package/dist/tour/tour-guide-labels.d.ts +8 -0
- package/dist/tour/tour-guide-labels.js +9 -0
- package/dist/tour/tour-guide-labels.js.map +1 -0
- package/dist/tour/tour-labels.js +9 -1
- package/dist/tour/tour-labels.js.map +1 -1
- package/dist/tour/tour.d.ts +144 -9
- package/dist/tour/tour.js +598 -59
- package/dist/tour/tour.js.map +1 -1
- package/dist/tour/use-tour-url-sync.d.ts +7 -2
- package/dist/tour/use-tour-url-sync.js +27 -1
- package/dist/tour/use-tour-url-sync.js.map +1 -1
- package/dist/tour.d.ts +2 -0
- package/dist/tour.js +3 -0
- package/dist/tour.js.map +1 -1
- package/dist/wizard/stepper-nav.js +15 -2
- package/dist/wizard/stepper-nav.js.map +1 -1
- package/dist/wizard/types.d.ts +11 -0
- package/dist/wizard/types.js.map +1 -1
- package/dist/wizard/use-wizard.js +11 -5
- package/dist/wizard/use-wizard.js.map +1 -1
- package/package.json +9 -1
- package/src/admin/user-roster.tsx +22 -12
- package/src/auth/verify-email.tsx +10 -0
- package/src/components/amount-input.tsx +5 -4
- package/src/components/autocomplete.tsx +20 -0
- package/src/components/combobox-core.tsx +2 -0
- package/src/components/combobox-suggestions.tsx +2 -0
- package/src/components/combobox.tsx +5 -1
- package/src/components/currency-select.tsx +95 -6
- package/src/components/data-table-mobile.tsx +99 -8
- package/src/components/data-table-row.tsx +1 -0
- package/src/components/data-table.tsx +9 -1
- package/src/components/dropdown.tsx +2 -0
- package/src/components/full-bleed-dialog.tsx +3 -0
- package/src/components/inline-entity-combobox.tsx +17 -0
- package/src/components/modal.tsx +3 -0
- package/src/components/number-input.tsx +8 -3
- package/src/components/numpad-sheet.tsx +4 -0
- package/src/components/picker-sheet.tsx +4 -0
- package/src/components/popover.tsx +2 -0
- package/src/i18n/defaults.ts +2 -0
- package/src/i18n/german.ts +13 -0
- package/src/i18n/kit-labels.tsx +3 -0
- package/src/i18n/locales/es.ts +13 -0
- package/src/i18n/locales/fr.ts +13 -0
- package/src/i18n/locales/hu.ts +13 -0
- package/src/i18n/locales/it.ts +13 -0
- package/src/i18n/locales/zh.ts +13 -0
- package/src/i18n/review.ts +1 -0
- package/src/lib/data-attributes.ts +14 -0
- package/src/lib/format.ts +2 -0
- package/src/lib/minus.ts +10 -0
- package/src/lib/quiet-focus.ts +32 -0
- package/src/testing/index.ts +8 -0
- package/src/testing/react.ts +59 -0
- package/src/testing/tour.ts +41 -0
- package/src/tour/tour-dock.ts +132 -0
- package/src/tour/tour-engine.ts +41 -0
- package/src/tour/tour-gates.ts +195 -0
- package/src/tour/tour-guide-button.tsx +64 -0
- package/src/tour/tour-guide-labels.ts +15 -0
- package/src/tour/tour-labels.ts +8 -0
- package/src/tour/tour.tsx +989 -86
- package/src/tour/use-tour-url-sync.ts +43 -4
- package/src/tour.ts +3 -0
- package/src/wizard/stepper-nav.tsx +23 -3
- package/src/wizard/types.ts +12 -0
- package/src/wizard/use-wizard.ts +16 -9
|
@@ -209,6 +209,10 @@ export function NumberPadSheet({
|
|
|
209
209
|
<div
|
|
210
210
|
role="group"
|
|
211
211
|
aria-label={text.panel}
|
|
212
|
+
// A kit layer, and the bottom layer a docked tour card stays clear of (0.50,
|
|
213
|
+
// docs/tours-harmonization.md §11.6).
|
|
214
|
+
data-kit-layer=""
|
|
215
|
+
data-tour-obstruction=""
|
|
212
216
|
// Keep the host input focused when tapping the pad: preventDefault on
|
|
213
217
|
// pointerdown blocks the focus/blur, while the buttons' click still fires.
|
|
214
218
|
onPointerDown={(e) => e.preventDefault()}
|
|
@@ -211,6 +211,10 @@ export function PickerSheet({
|
|
|
211
211
|
// caller has something to add.
|
|
212
212
|
{...rest}
|
|
213
213
|
ref={setSheet}
|
|
214
|
+
// A kit layer and a bottom layer a docked tour card stays clear of (0.50,
|
|
215
|
+
// docs/tours-harmonization.md §11.6).
|
|
216
|
+
data-kit-layer=""
|
|
217
|
+
data-tour-obstruction=""
|
|
214
218
|
role="dialog"
|
|
215
219
|
aria-modal="true"
|
|
216
220
|
// Without it the container cannot take focus, and `useFocusTrap` has nothing to
|
|
@@ -206,6 +206,8 @@ export function Popover({
|
|
|
206
206
|
// to remove one of them by accident.
|
|
207
207
|
{...rest}
|
|
208
208
|
ref={setPanel}
|
|
209
|
+
// A kit layer, for the tour's layer order (0.50, docs/tours-harmonization.md §11.6).
|
|
210
|
+
data-kit-layer=""
|
|
209
211
|
role="dialog"
|
|
210
212
|
id={panelId}
|
|
211
213
|
// The caller's own `aria-label` wins over {@link PopoverLabels.panel}, which
|
package/src/i18n/defaults.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
import { DEFAULT_WIZARD_LABELS } from "../wizard/wizard-labels";
|
|
15
15
|
import { DEFAULT_TOUR_LABELS } from "../tour/tour-labels";
|
|
16
16
|
import { DEFAULT_TOUR_CATALOGUE_LABELS } from "../tour/tour-catalogue-labels";
|
|
17
|
+
import { DEFAULT_TOUR_GUIDE_LABELS } from "../tour/tour-guide-labels";
|
|
17
18
|
import { DEFAULT_COMMAND_PALETTE_LABELS } from "../search/command-palette-labels";
|
|
18
19
|
import { DEFAULT_GLOBAL_SEARCH_LABELS } from "../search/global-search-labels";
|
|
19
20
|
import { DEFAULT_SERIES_CHART_LABELS } from "../components/series-chart-labels";
|
|
@@ -173,6 +174,7 @@ export const DEFAULT_UI_KIT_LABELS: UiKitLabels = {
|
|
|
173
174
|
wizard: DEFAULT_WIZARD_LABELS,
|
|
174
175
|
tour: DEFAULT_TOUR_LABELS,
|
|
175
176
|
tourCatalogue: DEFAULT_TOUR_CATALOGUE_LABELS,
|
|
177
|
+
tourGuide: DEFAULT_TOUR_GUIDE_LABELS,
|
|
176
178
|
commandPalette: DEFAULT_COMMAND_PALETTE_LABELS,
|
|
177
179
|
globalSearch: DEFAULT_GLOBAL_SEARCH_LABELS,
|
|
178
180
|
seriesChart: DEFAULT_SERIES_CHART_LABELS,
|
package/src/i18n/german.ts
CHANGED
|
@@ -634,6 +634,19 @@ export function germanLabels(numberLocale: string): UiKitLabels {
|
|
|
634
634
|
awaitClickHint: "Klicken Sie auf das hervorgehobene Element, um fortzufahren",
|
|
635
635
|
awaitInputHint: "Geben Sie im hervorgehobenen Feld einen Wert ein, um fortzufahren", // 0.46: TourStep.awaitInput (keksdose live #436).
|
|
636
636
|
step: (current, total) => `${n(current)} / ${n(total)}`,
|
|
637
|
+
// 0.50: guided form tours (docs/tours-harmonization.md §11).
|
|
638
|
+
leaveEmpty: "Leer lassen",
|
|
639
|
+
optional: "Optional",
|
|
640
|
+
gateHint: "Füllen Sie das hervorgehobene Feld aus, um fortzufahren",
|
|
641
|
+
gateReady: "Bereit. Drücken Sie Weiter oder die Eingabetaste, um fortzufahren.",
|
|
642
|
+
awaitGateHint: "Speichern Sie, um fortzufahren",
|
|
643
|
+
awaitGateRefused: "Das ist noch nicht gespeichert. Prüfen Sie das Formular und versuchen Sie es erneut.",
|
|
644
|
+
backToStep: (title) => `Zurück zu „${title}“`,
|
|
645
|
+
},
|
|
646
|
+
// 0.50: the form's "Guide me" (TourGuideButton, docs/tours-harmonization.md §11).
|
|
647
|
+
tourGuide: {
|
|
648
|
+
label: "Schritt für Schritt",
|
|
649
|
+
description: "Führt Sie Feld für Feld durch dieses Formular",
|
|
637
650
|
},
|
|
638
651
|
// 0.43.0 — docs/tours-harmonization.md §3, §8: the tours page. keksdose's reviewed words.
|
|
639
652
|
tourCatalogue: {
|
package/src/i18n/kit-labels.tsx
CHANGED
|
@@ -26,6 +26,7 @@ import type { TabsLabels } from "../components/tabs";
|
|
|
26
26
|
import type { WizardLabels } from "../wizard/types";
|
|
27
27
|
import type { TourLabels } from "../tour/tour";
|
|
28
28
|
import type { TourCatalogueLabels } from "../tour/tour-catalogue-labels";
|
|
29
|
+
import type { TourGuideLabels } from "../tour/tour-guide-labels";
|
|
29
30
|
import type { CommandPaletteLabels } from "../search/command-palette";
|
|
30
31
|
import type { GlobalSearchLabels } from "../search/global-search";
|
|
31
32
|
import type { MonthPickerLabels } from "../components/month-picker";
|
|
@@ -335,6 +336,8 @@ export interface UiKitLabels {
|
|
|
335
336
|
tour: TourLabels;
|
|
336
337
|
/** 0.43.0: the tours page — `TourCatalogue` and its card (docs/tours-harmonization.md §3). */
|
|
337
338
|
tourCatalogue: TourCatalogueLabels;
|
|
339
|
+
/** 0.50: the form's "Guide me" — `TourGuideButton` (docs/tours-harmonization.md §11). */
|
|
340
|
+
tourGuide: TourGuideLabels;
|
|
338
341
|
commandPalette: CommandPaletteLabels;
|
|
339
342
|
globalSearch: GlobalSearchLabels;
|
|
340
343
|
seriesChart: SeriesChartLabels;
|
package/src/i18n/locales/es.ts
CHANGED
|
@@ -617,6 +617,19 @@ export function uiKitLabelsEs(numberLocale = "es-ES"): UiKitLabels {
|
|
|
617
617
|
awaitClickHint: "Haga clic en el elemento resaltado para continuar",
|
|
618
618
|
awaitInputHint: "Introduzca un valor en el campo resaltado para continuar", // 0.46: TourStep.awaitInput (keksdose live #436).
|
|
619
619
|
step: (current, total) => `${n(current)} / ${n(total)}`,
|
|
620
|
+
// 0.50: guided form tours (docs/tours-harmonization.md §11).
|
|
621
|
+
leaveEmpty: "Dejar vacío",
|
|
622
|
+
optional: "Opcional",
|
|
623
|
+
gateHint: "Rellene el campo resaltado para continuar",
|
|
624
|
+
gateReady: "Listo. Pulse Siguiente o Intro para continuar.",
|
|
625
|
+
awaitGateHint: "Guarde para continuar",
|
|
626
|
+
awaitGateRefused: "Todavía no se ha guardado. Revise el formulario e inténtelo de nuevo.",
|
|
627
|
+
backToStep: (title) => `Volver a «${title}»`,
|
|
628
|
+
},
|
|
629
|
+
// 0.50: the form's "Guide me" (TourGuideButton, docs/tours-harmonization.md §11).
|
|
630
|
+
tourGuide: {
|
|
631
|
+
label: "Guíeme",
|
|
632
|
+
description: "Le guía por este formulario, campo a campo",
|
|
620
633
|
},
|
|
621
634
|
// 0.43.0 — docs/tours-harmonization.md §3, §8: the tours page.
|
|
622
635
|
tourCatalogue: {
|
package/src/i18n/locales/fr.ts
CHANGED
|
@@ -632,6 +632,19 @@ export function uiKitLabelsFr(numberLocale = "fr-FR"): UiKitLabels {
|
|
|
632
632
|
awaitClickHint: "Cliquez sur l’élément mis en évidence pour continuer",
|
|
633
633
|
awaitInputHint: "Saisissez une valeur dans le champ mis en évidence pour continuer", // 0.46: TourStep.awaitInput (keksdose live #436).
|
|
634
634
|
step: (current, total) => `${n(current)} / ${n(total)}`,
|
|
635
|
+
// 0.50: guided form tours (docs/tours-harmonization.md §11).
|
|
636
|
+
leaveEmpty: "Laisser vide",
|
|
637
|
+
optional: "Facultatif",
|
|
638
|
+
gateHint: "Remplissez le champ mis en évidence pour continuer",
|
|
639
|
+
gateReady: "Prêt. Appuyez sur Suivant ou sur Entrée pour continuer.",
|
|
640
|
+
awaitGateHint: "Enregistrez pour continuer",
|
|
641
|
+
awaitGateRefused: "Ce n’est pas encore enregistré. Vérifiez le formulaire et réessayez.",
|
|
642
|
+
backToStep: (title) => `Retour à «\u202f${title}\u202f»`,
|
|
643
|
+
},
|
|
644
|
+
// 0.50: the form's "Guide me" (TourGuideButton, docs/tours-harmonization.md §11).
|
|
645
|
+
tourGuide: {
|
|
646
|
+
label: "Me guider",
|
|
647
|
+
description: "Vous guide dans ce formulaire, champ par champ",
|
|
635
648
|
},
|
|
636
649
|
// 0.43.0 — docs/tours-harmonization.md §3, §8: the tours page. keksdose's reviewed words.
|
|
637
650
|
tourCatalogue: {
|
package/src/i18n/locales/hu.ts
CHANGED
|
@@ -619,6 +619,19 @@ export function uiKitLabelsHu(numberLocale = "hu-HU"): UiKitLabels {
|
|
|
619
619
|
awaitClickHint: "A folytatáshoz kattintson a kiemelt elemre",
|
|
620
620
|
awaitInputHint: "A folytatáshoz adjon meg egy értéket a kiemelt mezőben", // 0.46: TourStep.awaitInput (keksdose live #436).
|
|
621
621
|
step: (current, total) => `${n(current)} / ${n(total)}`,
|
|
622
|
+
// 0.50: guided form tours (docs/tours-harmonization.md §11).
|
|
623
|
+
leaveEmpty: "Hagyja üresen",
|
|
624
|
+
optional: "Nem kötelező",
|
|
625
|
+
gateHint: "A folytatáshoz töltse ki a kiemelt mezőt",
|
|
626
|
+
gateReady: "Kész. A folytatáshoz nyomja meg a Tovább gombot vagy az Entert.",
|
|
627
|
+
awaitGateHint: "A folytatáshoz mentsen",
|
|
628
|
+
awaitGateRefused: "Ez még nincs mentve. Ellenőrizze az űrlapot, és próbálja újra.",
|
|
629
|
+
backToStep: (title) => `Vissza ide: „${title}”`,
|
|
630
|
+
},
|
|
631
|
+
// 0.50: the form's "Guide me" (TourGuideButton, docs/tours-harmonization.md §11).
|
|
632
|
+
tourGuide: {
|
|
633
|
+
label: "Vezessen végig",
|
|
634
|
+
description: "Mezőről mezőre végigvezeti az űrlapon",
|
|
622
635
|
},
|
|
623
636
|
// 0.43.0 — docs/tours-harmonization.md §3, §8: the tours page.
|
|
624
637
|
tourCatalogue: {
|
package/src/i18n/locales/it.ts
CHANGED
|
@@ -621,6 +621,19 @@ export function uiKitLabelsIt(numberLocale = "it-IT"): UiKitLabels {
|
|
|
621
621
|
awaitClickHint: "Faccia clic sull’elemento evidenziato per continuare",
|
|
622
622
|
awaitInputHint: "Inserisca un valore nel campo evidenziato per continuare", // 0.46: TourStep.awaitInput (keksdose live #436).
|
|
623
623
|
step: (current, total) => `${n(current)} / ${n(total)}`,
|
|
624
|
+
// 0.50: guided form tours (docs/tours-harmonization.md §11).
|
|
625
|
+
leaveEmpty: "Lascia vuoto",
|
|
626
|
+
optional: "Facoltativo",
|
|
627
|
+
gateHint: "Compili il campo evidenziato per continuare",
|
|
628
|
+
gateReady: "Pronto. Prema Avanti o Invio per continuare.",
|
|
629
|
+
awaitGateHint: "Salvi per continuare",
|
|
630
|
+
awaitGateRefused: "Non è ancora stato salvato. Controlli il modulo e riprovi.",
|
|
631
|
+
backToStep: (title) => `Torna a «${title}»`,
|
|
632
|
+
},
|
|
633
|
+
// 0.50: the form's "Guide me" (TourGuideButton, docs/tours-harmonization.md §11).
|
|
634
|
+
tourGuide: {
|
|
635
|
+
label: "Guidami",
|
|
636
|
+
description: "La guida in questo modulo, campo per campo",
|
|
624
637
|
},
|
|
625
638
|
// 0.43.0 — docs/tours-harmonization.md §3, §8: the tours page. keksdose's reviewed words.
|
|
626
639
|
tourCatalogue: {
|
package/src/i18n/locales/zh.ts
CHANGED
|
@@ -600,6 +600,19 @@ export function uiKitLabelsZh(numberLocale = "zh-CN"): UiKitLabels {
|
|
|
600
600
|
awaitClickHint: "点击高亮的元素以继续",
|
|
601
601
|
awaitInputHint: "在高亮的字段中输入一个值以继续", // 0.46: TourStep.awaitInput (keksdose live #436).
|
|
602
602
|
step: (current, total) => `${n(current)} / ${n(total)}`,
|
|
603
|
+
// 0.50: guided form tours (docs/tours-harmonization.md §11).
|
|
604
|
+
leaveEmpty: "留空",
|
|
605
|
+
optional: "可选",
|
|
606
|
+
gateHint: "填写高亮的字段以继续",
|
|
607
|
+
gateReady: "已就绪。按“下一步”或回车键继续。",
|
|
608
|
+
awaitGateHint: "保存以继续",
|
|
609
|
+
awaitGateRefused: "尚未保存。请检查表单后重试。",
|
|
610
|
+
backToStep: (title) => `返回“${title}”`,
|
|
611
|
+
},
|
|
612
|
+
// 0.50: the form's "Guide me" (TourGuideButton, docs/tours-harmonization.md §11).
|
|
613
|
+
tourGuide: {
|
|
614
|
+
label: "引导我",
|
|
615
|
+
description: "逐个字段引导您填写此表单",
|
|
603
616
|
},
|
|
604
617
|
// 0.43.0 — docs/tours-harmonization.md §3, §8: the tours page.
|
|
605
618
|
tourCatalogue: {
|
package/src/i18n/review.ts
CHANGED
|
@@ -157,6 +157,7 @@ export const KIT_LABEL_SAMPLES: Readonly<Record<string, readonly (readonly unkno
|
|
|
157
157
|
"file.size": [[2_500_000]],
|
|
158
158
|
"wizard.step": [[2, 4]],
|
|
159
159
|
"tour.step": [[2, 4]],
|
|
160
|
+
"tour.backToStep": [["{{title}}"]],
|
|
160
161
|
"globalSearch.shortcut": [["{{keys}}"]],
|
|
161
162
|
"pieChart.slice": [["{{label}}", "{{value}}", "{{percent}}"]],
|
|
162
163
|
"sparkline.rising": [["{{first}}", "{{last}}"]],
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A component's `data-*` props, for its root element (0.50, keksdose): `data-tour`,
|
|
3
|
+
* `data-testid` and the like reach the DOM even when the props are not spread there
|
|
4
|
+
* whole. TypeScript does not check hyphenated JSX attributes, so without this a
|
|
5
|
+
* `data-tour` on such a component vanished without an error.
|
|
6
|
+
*/
|
|
7
|
+
export type DataAttributes = { [attribute: `data-${string}`]: string | number | boolean | undefined };
|
|
8
|
+
|
|
9
|
+
/** Only the `data-*` entries of `props`. */
|
|
10
|
+
export function dataAttributes(props: object): DataAttributes {
|
|
11
|
+
const out: DataAttributes = {};
|
|
12
|
+
for (const [k, v] of Object.entries(props)) if (k.startsWith("data-")) out[k as `data-${string}`] = v;
|
|
13
|
+
return out;
|
|
14
|
+
}
|
package/src/lib/format.ts
CHANGED
|
@@ -5,6 +5,8 @@ import { formatWithMinus, type MinusStyle } from "./minus";
|
|
|
5
5
|
import { roundHalfAway } from "./round";
|
|
6
6
|
|
|
7
7
|
export type { MinusStyle } from "./minus";
|
|
8
|
+
// 0.50 (KIT-1 follow-up): the kit's minus for an app's OWN `Intl.NumberFormat` wrapper.
|
|
9
|
+
export { formatWithMinus } from "./minus";
|
|
8
10
|
|
|
9
11
|
/**
|
|
10
12
|
* Number, money, percentage, date and relative-time formatting — `Intl`, in one place,
|
package/src/lib/minus.ts
CHANGED
|
@@ -24,6 +24,16 @@ const MINUS_PARTS: ReadonlySet<string> = new Set(["minusSign", "exponentMinusSig
|
|
|
24
24
|
* `Intl` marks as a minus are touched — a hyphen inside a currency's name
|
|
25
25
|
* ("Bosnia-Herzegovina convertible marks") stays — and only when the text holds the
|
|
26
26
|
* other sign at all, so a positive figure costs no `formatToParts`.
|
|
27
|
+
*
|
|
28
|
+
* Public since 0.50 (the KIT-1 follow-up): an app that keeps its OWN `Intl.NumberFormat`
|
|
29
|
+
* wrapper (a currency with app-specific digits, a quantity) calls it in place of
|
|
30
|
+
* `formatter.format(value)`, so its negatives match the kit's "−". Where the wrapper only
|
|
31
|
+
* re-states `formatMoney` / `formatNumber`, route it through those instead.
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* const eur = new Intl.NumberFormat("en", { style: "currency", currency: "EUR" });
|
|
35
|
+
* formatWithMinus(eur, -150); // "−€150.00"
|
|
36
|
+
* formatWithMinus(eur, -150, "ascii"); // "-€150.00", for a CSV
|
|
27
37
|
*/
|
|
28
38
|
export function formatWithMinus(
|
|
29
39
|
formatter: Intl.NumberFormat,
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Focus that is not a request to open anything (0.50.1, keksdose B1).
|
|
3
|
+
*
|
|
4
|
+
* A guided tour focuses a field step's first control on arrival (tours §11.8). The kit's
|
|
5
|
+
* pickers that open their list on focus — Combobox (and the category pickers built on it),
|
|
6
|
+
* Autocomplete, InlineEntityCombobox — opened it then too, and the list, a later layer
|
|
7
|
+
* (§11.6), covered the tour card with its Next and "Leave empty".
|
|
8
|
+
*
|
|
9
|
+
* {@link focusQuietly} marks the element for the length of its synchronous `focus()` call;
|
|
10
|
+
* a picker's `onFocus` asks {@link isQuietFocus} and, when it is, takes the focus without
|
|
11
|
+
* opening. React dispatches `onFocus` inside the native `focusin`, which `focus()` fires
|
|
12
|
+
* synchronously, so the mark is seen by exactly that focus and by no later one. A click or
|
|
13
|
+
* Tab of the person's own opens as before; from a quiet focus, ArrowDown, typing or a click
|
|
14
|
+
* opens the list.
|
|
15
|
+
*/
|
|
16
|
+
let quiet: Element | null = null;
|
|
17
|
+
|
|
18
|
+
/** `el.focus(options)`, telling the kit's open-on-focus pickers not to open. */
|
|
19
|
+
export function focusQuietly(el: HTMLElement, options?: FocusOptions): void {
|
|
20
|
+
const before = quiet;
|
|
21
|
+
quiet = el;
|
|
22
|
+
try {
|
|
23
|
+
el.focus(options);
|
|
24
|
+
} finally {
|
|
25
|
+
quiet = before;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** True while `el` is being focused by {@link focusQuietly}. */
|
|
30
|
+
export function isQuietFocus(el: Element): boolean {
|
|
31
|
+
return quiet === el;
|
|
32
|
+
}
|
package/src/testing/index.ts
CHANGED
|
@@ -24,3 +24,11 @@ export {
|
|
|
24
24
|
type StubbedBoxSize,
|
|
25
25
|
type ViewportOptions,
|
|
26
26
|
} from "./viewport";
|
|
27
|
+
// 0.50.1 (keksdose N4): a valid `TourStepContext` for a test that calls a step's
|
|
28
|
+
// `beforeStep` or `when` itself — `beforeStep!(tourStepContext())`.
|
|
29
|
+
export {
|
|
30
|
+
tourStepContext,
|
|
31
|
+
type TestTourStepContext,
|
|
32
|
+
type TourStepContextOptions,
|
|
33
|
+
type TourStepVia,
|
|
34
|
+
} from "./tour";
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { fireEvent, screen, within } from "@testing-library/react";
|
|
2
|
+
|
|
3
|
+
// `@eifi1/ui-kit/testing/react` (0.50) — helpers that drive the kit's components through
|
|
4
|
+
// Testing Library, from an app's own test suite.
|
|
5
|
+
//
|
|
6
|
+
// A sibling of `@eifi1/ui-kit/testing`, not part of it: that entry is pure (it reaches no
|
|
7
|
+
// package, so it runs under node or jsdom alike), and this one needs
|
|
8
|
+
// `@testing-library/react`, an optional peer only a test suite installs. Neither is part of
|
|
9
|
+
// the barrel, so an app's production bundle never carries either.
|
|
10
|
+
|
|
11
|
+
/** The words of the row's "⋯" button, when the app's tests render translated kit labels. */
|
|
12
|
+
export interface RowMenuLabels {
|
|
13
|
+
/** The button's name on a row with no `name`. Default "Actions". */
|
|
14
|
+
actions?: string;
|
|
15
|
+
/** The button's name given the row's. Default `Actions for ${name}`. */
|
|
16
|
+
actionsFor?: (name: string) => string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// The kit's English defaults (DEFAULT_ROW_ACTIONS_LABELS, and the user roster's), written
|
|
20
|
+
// out rather than imported: the label module's type import would walk this entry into the
|
|
21
|
+
// components. src/testing/__tests__/react.test.tsx holds them equal.
|
|
22
|
+
const ACTIONS = "Actions";
|
|
23
|
+
const ACTIONS_FOR = (name: string) => `Actions for ${name}`;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Open a row's "⋯" menu and find one of its actions — the entry, unclicked, so a test can
|
|
27
|
+
* check it (locked, described) before it clicks it.
|
|
28
|
+
*
|
|
29
|
+
* `row` is the row's name as the app gave it to `RowActions` / `rowActionsColumn` /
|
|
30
|
+
* `UserRoster` ("Actions for <row>"); `undefined` takes the FIRST "⋯" on the page, named
|
|
31
|
+
* or not. Since 0.49 (KIT-12) a `tone: "danger"` action lives in that menu rather than as
|
|
32
|
+
* an icon in the row, and at Large text every action does.
|
|
33
|
+
*
|
|
34
|
+
* The entries are BUTTONS in a dialog popover named like the "⋯" button — not `menuitem`s:
|
|
35
|
+
* `getByRole("menuitem")` finds nothing there. A danger action asks for confirmation once
|
|
36
|
+
* clicked (an `alertdialog`), unless the app turned that off.
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* fireEvent.click(await rowMenuAction("Ada Example", "Delete"));
|
|
40
|
+
* fireEvent.click(await screen.findByRole("button", { name: "Confirm" }));
|
|
41
|
+
*/
|
|
42
|
+
export async function rowMenuAction(
|
|
43
|
+
row: string | undefined,
|
|
44
|
+
action: string | RegExp,
|
|
45
|
+
labels: RowMenuLabels = {},
|
|
46
|
+
): Promise<HTMLElement> {
|
|
47
|
+
const actions = labels.actions ?? ACTIONS;
|
|
48
|
+
const actionsFor = labels.actionsFor ?? ACTIONS_FOR;
|
|
49
|
+
// Any row's name: the words around the name, whatever the language puts first.
|
|
50
|
+
const [before, after] = actionsFor("\u0000").split("\u0000");
|
|
51
|
+
const anyRow = (name: string) =>
|
|
52
|
+
name === actions ||
|
|
53
|
+
(name.length > before.length + after.length && name.startsWith(before) && name.endsWith(after));
|
|
54
|
+
const trigger = (await screen.findAllByRole("button", { name: row === undefined ? anyRow : actionsFor(row) }))[0];
|
|
55
|
+
fireEvent.click(trigger);
|
|
56
|
+
const title = trigger.getAttribute("aria-label") ?? trigger.textContent ?? "";
|
|
57
|
+
const menu = await screen.findByRole("dialog", { name: title });
|
|
58
|
+
return within(menu).getByRole("button", { name: action });
|
|
59
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 0.50.1 (keksdose N4): a `TourStepContext` for a test that calls a step's `beforeStep` or
|
|
3
|
+
* `when` itself. 0.50 gave both the context, so `beforeStep!()` is a type error;
|
|
4
|
+
* `beforeStep!(tourStepContext())` is the drop-in.
|
|
5
|
+
*
|
|
6
|
+
* Spelled out here rather than imported from the tour module, so `@eifi1/ui-kit/testing`
|
|
7
|
+
* still reaches no package (the tour module reaches React); the kit's own test pins that it
|
|
8
|
+
* is assignable to `TourStepContext`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** How a step is entered: `TourStepContext["via"]`. */
|
|
12
|
+
export type TourStepVia = "start" | "at" | "next" | "back" | "goto";
|
|
13
|
+
|
|
14
|
+
/** The shape of `TourStepContext` (`@eifi1/ui-kit/tour`). */
|
|
15
|
+
export interface TestTourStepContext {
|
|
16
|
+
gate(key: string): boolean | undefined;
|
|
17
|
+
via: TourStepVia;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface TourStepContextOptions {
|
|
21
|
+
/** How the step is entered. Default `"start"`. */
|
|
22
|
+
via?: TourStepVia;
|
|
23
|
+
/**
|
|
24
|
+
* The published gates, as the form would publish them: `{ "txn.transfer": true }`. A key
|
|
25
|
+
* left out answers `undefined` (nobody publishes it). Default: none.
|
|
26
|
+
*/
|
|
27
|
+
gates?: Readonly<Record<string, boolean | undefined>>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A valid step context: `via: "start"` and no gates published, unless `options` say
|
|
32
|
+
* otherwise. `step.beforeStep!(tourStepContext())`, `step.when!(tourStepContext({ gates:
|
|
33
|
+
* { "txn.transfer": true } }))`, `tourStepContext({ via: "at" })` for a "Guide me" entry.
|
|
34
|
+
*/
|
|
35
|
+
export function tourStepContext(options: TourStepContextOptions = {}): TestTourStepContext {
|
|
36
|
+
const gates = options.gates ?? {};
|
|
37
|
+
return {
|
|
38
|
+
gate: (key) => (Object.prototype.hasOwnProperty.call(gates, key) ? gates[key] : undefined),
|
|
39
|
+
via: options.via ?? "start",
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type { CSSProperties } from "react";
|
|
2
|
+
import { readKeyboardInset } from "../hooks/use-anchored-panel";
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
* # The docked card (docs/tours-harmonization.md §11.7, contract round 054 §6)
|
|
6
|
+
*
|
|
7
|
+
* On a phone a field step's card is pinned full-width to the top or bottom edge of the
|
|
8
|
+
* USABLE BAND — the visual viewport less the keyboard, raised above the highest
|
|
9
|
+
* `[data-tour-obstruction]` (the number pad, a picker sheet) — on the edge away from the
|
|
10
|
+
* spotlighted field, so it covers neither the field nor the keyboard nor the pad.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export type DockEdge = "top" | "bottom";
|
|
14
|
+
|
|
15
|
+
export interface DockRect {
|
|
16
|
+
top: number;
|
|
17
|
+
left: number;
|
|
18
|
+
width: number;
|
|
19
|
+
height: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The band, in layout-viewport px (what `position: fixed` measures from). */
|
|
23
|
+
export interface Band {
|
|
24
|
+
top: number;
|
|
25
|
+
bottom: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Below this the docked layout applies (Tailwind's `sm`). */
|
|
29
|
+
export const DOCK_MAX_WIDTH = 640;
|
|
30
|
+
/** The gutter between the card and the band's edge, and between the card and the field. */
|
|
31
|
+
const GUTTER = 8;
|
|
32
|
+
/** An obstruction whose top is within this of the band's top covers the screen. */
|
|
33
|
+
const FULL_SCREEN_SLACK = 64;
|
|
34
|
+
/** Under this much free space on its edge the card flips (the hysteresis floor). */
|
|
35
|
+
const MIN_DOCK = 140;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The usable band now: the visual viewport while a keyboard is up (`readKeyboardInset`'s
|
|
39
|
+
* rules — pinch zoom is not a keyboard, 100 px minimum), else the layout viewport; its
|
|
40
|
+
* bottom raised to the top of the highest visible obstruction that holds neither the field
|
|
41
|
+
* nor the card. An obstruction that starts at the band's top (a full-screen picker, opened
|
|
42
|
+
* over everything) is not a bottom layer and is ignored; a tall one (the number pad at
|
|
43
|
+
* Large text is over half a phone) still counts.
|
|
44
|
+
*/
|
|
45
|
+
export function readBand(target: Element | null, card: Element | null): Band {
|
|
46
|
+
const vh = window.innerHeight;
|
|
47
|
+
let top = 0;
|
|
48
|
+
let bottom = vh;
|
|
49
|
+
const vv = window.visualViewport;
|
|
50
|
+
if (vv && readKeyboardInset() > 0) {
|
|
51
|
+
top = vv.offsetTop;
|
|
52
|
+
bottom = vv.offsetTop + vv.height;
|
|
53
|
+
}
|
|
54
|
+
for (const el of Array.from(document.querySelectorAll("[data-tour-obstruction]"))) {
|
|
55
|
+
if ((target && el.contains(target)) || (card && el.contains(card))) continue;
|
|
56
|
+
const r = el.getBoundingClientRect();
|
|
57
|
+
if (r.width <= 0 || r.height <= 0 || r.top <= top + FULL_SCREEN_SLACK) continue;
|
|
58
|
+
if (r.top < bottom) bottom = r.top;
|
|
59
|
+
}
|
|
60
|
+
return { top, bottom };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The card's free space on `edge`, between the band's edge and the spotlight. */
|
|
64
|
+
function room(edge: DockEdge, spot: DockRect, band: Band): number {
|
|
65
|
+
return edge === "bottom" ? band.bottom - (spot.top + spot.height) - GUTTER * 2 : spot.top - band.top - GUTTER * 2;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The edge away from the field: the bottom when the spotlight's centre is in the band's
|
|
70
|
+
* upper half, else the top. With a `previous` edge, it stays unless the card would have to
|
|
71
|
+
* cover the spotlight there (no flip on every keyboard animation frame).
|
|
72
|
+
*/
|
|
73
|
+
export function dockEdge(spot: DockRect, band: Band, previous: DockEdge | null, cardHeight: number): DockEdge {
|
|
74
|
+
const preferred: DockEdge = spot.top + spot.height / 2 < band.top + (band.bottom - band.top) / 2 ? "bottom" : "top";
|
|
75
|
+
if (!previous || previous === preferred) return preferred;
|
|
76
|
+
return room(previous, spot, band) >= Math.min(MIN_DOCK, cardHeight) ? previous : preferred;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The docked card's position: full-width with 8 px (safe-area aware) gutters, its height
|
|
80
|
+
* capped to the free space so its body scrolls and its title and buttons show. */
|
|
81
|
+
export function dockStyle(edge: DockEdge, spot: DockRect | null, band: Band): CSSProperties {
|
|
82
|
+
const vh = window.innerHeight;
|
|
83
|
+
const cap = spot ? Math.max(MIN_DOCK / 2, room(edge, spot, band)) : band.bottom - band.top - GUTTER * 2;
|
|
84
|
+
const base: CSSProperties = {
|
|
85
|
+
left: `max(${GUTTER}px, env(safe-area-inset-left))`,
|
|
86
|
+
right: `max(${GUTTER}px, env(safe-area-inset-right))`,
|
|
87
|
+
width: "auto",
|
|
88
|
+
maxWidth: "none",
|
|
89
|
+
maxHeight: cap,
|
|
90
|
+
};
|
|
91
|
+
if (edge === "top") {
|
|
92
|
+
return { ...base, top: band.top <= 0 ? `max(${GUTTER}px, env(safe-area-inset-top))` : band.top + GUTTER, bottom: "auto" };
|
|
93
|
+
}
|
|
94
|
+
const fromBottom = vh - band.bottom;
|
|
95
|
+
return {
|
|
96
|
+
...base,
|
|
97
|
+
top: "auto",
|
|
98
|
+
bottom: fromBottom <= 0 ? `max(${GUTTER}px, env(safe-area-inset-bottom))` : fromBottom + GUTTER,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The target's nearest ancestor that scrolls vertically now (a sheet's body), or null. */
|
|
103
|
+
export function scrollParent(el: Element): HTMLElement | null {
|
|
104
|
+
for (let node = el.parentElement; node && node !== document.body; node = node.parentElement) {
|
|
105
|
+
const { overflowY } = getComputedStyle(node);
|
|
106
|
+
if ((overflowY === "auto" || overflowY === "scroll") && node.scrollHeight > node.clientHeight) return node;
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Scroll the field into the half of the band away from the card, through its nearest
|
|
113
|
+
* scrollable ancestor (the window only when there is none — the page behind a sheet never
|
|
114
|
+
* moves). A field already inside that half is left where it is. Returns the edge the card
|
|
115
|
+
* takes.
|
|
116
|
+
*/
|
|
117
|
+
export function scrollIntoFreeHalf(el: Element, card: Element | null, previous: DockEdge | null): DockEdge {
|
|
118
|
+
const r = el.getBoundingClientRect();
|
|
119
|
+
const band = readBand(el, card);
|
|
120
|
+
const edge = dockEdge(r, band, previous, card instanceof HTMLElement ? card.offsetHeight : MIN_DOCK);
|
|
121
|
+
const mid = band.top + (band.bottom - band.top) / 2;
|
|
122
|
+
const half = edge === "bottom" ? { top: band.top + GUTTER, bottom: mid } : { top: mid, bottom: band.bottom - GUTTER };
|
|
123
|
+
if (r.top >= half.top && r.top + r.height <= half.bottom) return edge;
|
|
124
|
+
// Centred in the half, or its top at the half's top when it is taller than the half.
|
|
125
|
+
const delta =
|
|
126
|
+
r.height > half.bottom - half.top ? r.top - half.top : r.top + r.height / 2 - (half.top + half.bottom) / 2;
|
|
127
|
+
const scroller = scrollParent(el);
|
|
128
|
+
if (scroller) scroller.scrollTop += delta;
|
|
129
|
+
// A field in a kit layer with nothing to scroll: the page behind it stays put.
|
|
130
|
+
else if (!el.closest("[data-kit-layer]")) window.scrollBy?.(0, delta);
|
|
131
|
+
return edge;
|
|
132
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 0.50.1 (keksdose N1): the tour engine a launcher is handed as its fourth argument — one
|
|
3
|
+
* object for the life of the provider (or of `useTourUrlSync`) whose fields always read the
|
|
4
|
+
* engine's LATEST render: `engine.active`, `engine.index` after an `await` are current, and
|
|
5
|
+
* `engine.stop()` mid-run stops the run that is up now. Internal: the type is `TourEngine`.
|
|
6
|
+
*
|
|
7
|
+
* `set` is called from a layout effect with each render's engine; `engine` reads it.
|
|
8
|
+
*/
|
|
9
|
+
export function liveEngine<T extends object>(
|
|
10
|
+
keys: readonly (keyof T & string)[],
|
|
11
|
+
initial: T | null = null,
|
|
12
|
+
): { engine: T; set: (current: T) => void } {
|
|
13
|
+
let current = initial;
|
|
14
|
+
const read = () => {
|
|
15
|
+
if (!current)
|
|
16
|
+
throw new Error(
|
|
17
|
+
"[ui-kit] The tour engine was read before its provider rendered.",
|
|
18
|
+
);
|
|
19
|
+
return current;
|
|
20
|
+
};
|
|
21
|
+
const engine = {} as T;
|
|
22
|
+
for (const key of keys) {
|
|
23
|
+
Object.defineProperty(engine, key, {
|
|
24
|
+
enumerable: true,
|
|
25
|
+
get: () => {
|
|
26
|
+
const value = read()[key];
|
|
27
|
+
// A method is looked up when it is CALLED, so a kept `engine.stop` is the latest too.
|
|
28
|
+
return typeof value === "function"
|
|
29
|
+
? (...args: unknown[]) =>
|
|
30
|
+
(read()[key] as (...a: unknown[]) => unknown)(...args)
|
|
31
|
+
: value;
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
engine,
|
|
37
|
+
set: (next) => {
|
|
38
|
+
current = next;
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|