@clicoh/orion-library 1.2.0-beta.20 → 1.2.0-beta.22

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 (93) hide show
  1. package/assets/avatares/caja-chispa.png +0 -0
  2. package/assets/avatares/caja.png +0 -0
  3. package/assets/avatares/cohete.png +0 -0
  4. package/assets/avatares/dino.png +0 -0
  5. package/assets/avatares/pulpo-lentes.png +0 -0
  6. package/assets/avatares/pulpo-verde.png +0 -0
  7. package/assets/avatares/pulpo.png +0 -0
  8. package/assets/cifras/alerta.png +0 -0
  9. package/assets/cifras/duda.png +0 -0
  10. package/assets/cifras/error.png +0 -0
  11. package/assets/cifras/monedas.png +0 -0
  12. package/assets/cifras/reloj.png +0 -0
  13. package/assets/cifras/visto.png +0 -0
  14. package/assets/css/expresivo.css +111 -10
  15. package/dist/components/02-molecules/ImageViewer/ImageViewer.d.ts +35 -3
  16. package/dist/components/02-molecules/ImageViewer/ImageViewer.d.ts.map +1 -1
  17. package/dist/components/02-molecules/ImageViewer/ImageViewer.jsx +193 -31
  18. package/dist/components/02-molecules/ImageViewer/ImageViewer.jsx.map +1 -1
  19. package/dist/components/02-molecules/ImageViewer/ImageViewerDocument.d.ts +52 -0
  20. package/dist/components/02-molecules/ImageViewer/ImageViewerDocument.d.ts.map +1 -0
  21. package/dist/components/02-molecules/ImageViewer/ImageViewerDocument.jsx +78 -0
  22. package/dist/components/02-molecules/ImageViewer/ImageViewerDocument.jsx.map +1 -0
  23. package/dist/components/02-molecules/ImageViewer/ImageViewerImage.d.ts +8 -2
  24. package/dist/components/02-molecules/ImageViewer/ImageViewerImage.d.ts.map +1 -1
  25. package/dist/components/02-molecules/ImageViewer/ImageViewerImage.jsx +10 -4
  26. package/dist/components/02-molecules/ImageViewer/ImageViewerImage.jsx.map +1 -1
  27. package/dist/components/02-molecules/ImageViewer/usePdfPages.d.ts +20 -0
  28. package/dist/components/02-molecules/ImageViewer/usePdfPages.d.ts.map +1 -0
  29. package/dist/components/02-molecules/ImageViewer/usePdfPages.js +113 -0
  30. package/dist/components/02-molecules/ImageViewer/usePdfPages.js.map +1 -0
  31. package/dist/components/03-organisms/DataTable/DataTable.d.ts.map +1 -1
  32. package/dist/components/03-organisms/DataTable/DataTable.jsx +1 -1
  33. package/dist/components/03-organisms/DataTable/DataTable.jsx.map +1 -1
  34. package/expresivo/ExpChip/ExpChip.d.ts +15 -5
  35. package/expresivo/ExpChip/ExpChip.d.ts.map +1 -1
  36. package/expresivo/ExpChip/ExpChip.jsx +25 -4
  37. package/expresivo/ExpChip/ExpChip.jsx.map +1 -1
  38. package/expresivo/ExpJourneyTrack/ExpJourneyTrack.d.ts +4 -0
  39. package/expresivo/ExpJourneyTrack/ExpJourneyTrack.d.ts.map +1 -1
  40. package/expresivo/ExpJourneyTrack/ExpJourneyTrack.jsx +6 -2
  41. package/expresivo/ExpJourneyTrack/ExpJourneyTrack.jsx.map +1 -1
  42. package/expresivo/ExpLadder/ExpLadder.d.ts +42 -0
  43. package/expresivo/ExpLadder/ExpLadder.d.ts.map +1 -0
  44. package/expresivo/ExpLadder/ExpLadder.jsx +82 -0
  45. package/expresivo/ExpLadder/ExpLadder.jsx.map +1 -0
  46. package/expresivo/ExpLadder/index.d.ts +3 -0
  47. package/expresivo/ExpLadder/index.d.ts.map +1 -0
  48. package/expresivo/ExpLadder/index.js +2 -0
  49. package/expresivo/ExpLadder/index.js.map +1 -0
  50. package/expresivo/ExpPageHero/ExpPageHero.d.ts +3 -3
  51. package/expresivo/ExpPageHero/ExpPageHero.jsx +4 -4
  52. package/expresivo/ExpPageHero/ExpPageHero.jsx.map +1 -1
  53. package/expresivo/ExpPillButton/ExpPillButton.d.ts +4 -1
  54. package/expresivo/ExpPillButton/ExpPillButton.d.ts.map +1 -1
  55. package/expresivo/ExpPillButton/ExpPillButton.jsx +6 -4
  56. package/expresivo/ExpPillButton/ExpPillButton.jsx.map +1 -1
  57. package/expresivo/ExpRepoAvatar/ExpRepoAvatar.d.ts +28 -0
  58. package/expresivo/ExpRepoAvatar/ExpRepoAvatar.d.ts.map +1 -0
  59. package/expresivo/ExpRepoAvatar/ExpRepoAvatar.jsx +32 -0
  60. package/expresivo/ExpRepoAvatar/ExpRepoAvatar.jsx.map +1 -0
  61. package/expresivo/ExpRepoAvatar/catalog.d.ts +6 -0
  62. package/expresivo/ExpRepoAvatar/catalog.d.ts.map +1 -0
  63. package/expresivo/ExpRepoAvatar/catalog.js +18 -0
  64. package/expresivo/ExpRepoAvatar/catalog.js.map +1 -0
  65. package/expresivo/ExpRepoAvatar/index.d.ts +4 -0
  66. package/expresivo/ExpRepoAvatar/index.d.ts.map +1 -0
  67. package/expresivo/ExpRepoAvatar/index.js +2 -0
  68. package/expresivo/ExpRepoAvatar/index.js.map +1 -0
  69. package/expresivo/ExpSearchField/ExpSearchField.d.ts +49 -0
  70. package/expresivo/ExpSearchField/ExpSearchField.d.ts.map +1 -0
  71. package/expresivo/ExpSearchField/ExpSearchField.jsx +81 -0
  72. package/expresivo/ExpSearchField/ExpSearchField.jsx.map +1 -0
  73. package/expresivo/ExpSearchField/index.d.ts +3 -0
  74. package/expresivo/ExpSearchField/index.d.ts.map +1 -0
  75. package/expresivo/ExpSearchField/index.js +2 -0
  76. package/expresivo/ExpSearchField/index.js.map +1 -0
  77. package/expresivo/ExpStatIcon/ExpStatIcon.d.ts +25 -0
  78. package/expresivo/ExpStatIcon/ExpStatIcon.d.ts.map +1 -0
  79. package/expresivo/ExpStatIcon/ExpStatIcon.jsx +29 -0
  80. package/expresivo/ExpStatIcon/ExpStatIcon.jsx.map +1 -0
  81. package/expresivo/ExpStatIcon/catalog.d.ts +6 -0
  82. package/expresivo/ExpStatIcon/catalog.d.ts.map +1 -0
  83. package/expresivo/ExpStatIcon/catalog.js +16 -0
  84. package/expresivo/ExpStatIcon/catalog.js.map +1 -0
  85. package/expresivo/ExpStatIcon/index.d.ts +4 -0
  86. package/expresivo/ExpStatIcon/index.d.ts.map +1 -0
  87. package/expresivo/ExpStatIcon/index.js +2 -0
  88. package/expresivo/ExpStatIcon/index.js.map +1 -0
  89. package/expresivo/index.d.ts +4 -0
  90. package/expresivo/index.d.ts.map +1 -1
  91. package/expresivo/index.js +4 -0
  92. package/expresivo/index.js.map +1 -1
  93. package/package.json +2 -1
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
@@ -524,6 +524,59 @@
524
524
  clip-path: polygon(0 0, 100% 0, 100% 100%, 50% 68%, 0 100%);
525
525
  }
526
526
 
527
+ /* ExpSearchField: the same relief as the field skin, with its action inside */
528
+
529
+ .exp-search {
530
+ border: 1px solid var(--exp-field-line);
531
+ background: var(--exp-field-bg);
532
+ box-shadow:
533
+ 0 4px 14px var(--exp-field-drop),
534
+ 0 1px 2px rgba(34, 35, 41, 0.06),
535
+ inset 0 1px 0 var(--exp-field-gloss);
536
+ transition:
537
+ transform 260ms var(--exp-ease-spring),
538
+ box-shadow 260ms ease,
539
+ border-color 200ms ease,
540
+ background 200ms ease;
541
+ }
542
+
543
+ .exp-search[data-state='idle']:is(:hover, :focus-within) {
544
+ border-color: var(--exp-accent-soft);
545
+ background: var(--exp-field-bg-hover);
546
+ box-shadow:
547
+ 0 10px 28px var(--exp-field-glow),
548
+ 0 2px 6px rgba(106, 93, 249, 0.12),
549
+ inset 0 1px 0 var(--exp-field-gloss);
550
+ }
551
+
552
+ .exp-search[data-state='idle']:focus-within {
553
+ border-color: var(--exp-accent);
554
+ }
555
+
556
+ .exp-search[data-state][data-invalid] {
557
+ border-color: var(--color-error);
558
+ }
559
+
560
+ .exp-search[data-state='disabled'] {
561
+ opacity: 0.6;
562
+ cursor: not-allowed;
563
+ }
564
+
565
+ .exp-search-input {
566
+ color: var(--exp-field-ink);
567
+ }
568
+
569
+ .exp-search-lens {
570
+ color: var(--exp-field-icon);
571
+ transition:
572
+ color 200ms ease,
573
+ transform 260ms var(--exp-ease-spring);
574
+ }
575
+
576
+ .exp-search[data-state='idle']:is(:hover, :focus-within) .exp-search-lens {
577
+ color: var(--exp-accent);
578
+ }
579
+
527
580
  /* Skin: titles ---------------------------------------------------------- */
528
581
 
529
582
  :root:has(.exp-scope[data-exp-titles='playful']) [data-orion-part='heading'] {
@@ -817,15 +870,36 @@
817
870
  var(--exp-card-bg);
818
871
  }
819
872
 
873
+ /* Skin: framed tables (DataTable card) ---------------------------------- */
874
+
875
+ /* A framed table reads as one card: the brand band along the top, like
876
+ `Card` `accent`, and core rows inside even with tables="cards". */
877
+ :root:has(.exp-scope) [data-orion-part='data-table'][data-modifier='card'] {
878
+ overflow: hidden;
879
+ border-color: var(--exp-card-edge);
880
+ border-radius: 16px;
881
+ padding-top: 4px;
882
+ background:
883
+ var(--exp-card-band) top / 100% 4px no-repeat,
884
+ var(--exp-card-bg);
885
+ box-shadow: var(--exp-card-drop);
886
+ }
887
+
820
888
  /* Skin: table rows as cards (ExpScope tables="cards") ------------------- */
821
889
 
822
- :root:has(.exp-scope[data-exp-tables='cards']) [data-orion-part='table'] {
890
+ :root:has(.exp-scope[data-exp-tables='cards'])
891
+ [data-orion-part='table']:not(
892
+ [data-orion-part='data-table'][data-modifier='card'] *
893
+ ) {
823
894
  margin-top: -8px;
824
895
  border-collapse: separate;
825
896
  border-spacing: 0 8px;
826
897
  }
827
898
 
828
- :root:has(.exp-scope[data-exp-tables='cards']) [data-orion-part='table-head'] {
899
+ :root:has(.exp-scope[data-exp-tables='cards'])
900
+ [data-orion-part='table-head']:not(
901
+ [data-orion-part='data-table'][data-modifier='card'] *
902
+ ) {
829
903
  border: 0;
830
904
  background: transparent;
831
905
  color: var(--exp-field-label);
@@ -834,7 +908,9 @@
834
908
  }
835
909
 
836
910
  :root:has(.exp-scope[data-exp-tables='cards'])
837
- [data-orion-part='table-head']
911
+ [data-orion-part='table-head']:not(
912
+ [data-orion-part='data-table'][data-modifier='card'] *
913
+ )
838
914
  > th {
839
915
  padding: 10px 16px 2px;
840
916
  font-weight: 400;
@@ -842,19 +918,26 @@
842
918
  }
843
919
 
844
920
  :root:has(.exp-scope[data-exp-tables='cards'][data-exp-titles='playful'])
845
- [data-orion-part='table-head']
921
+ [data-orion-part='table-head']:not(
922
+ [data-orion-part='data-table'][data-modifier='card'] *
923
+ )
846
924
  > th {
847
925
  font-family: var(--exp-title-font);
848
926
  }
849
927
 
850
- :root:has(.exp-scope[data-exp-tables='cards']) [data-orion-part='table-row'] {
928
+ :root:has(.exp-scope[data-exp-tables='cards'])
929
+ [data-orion-part='table-row']:not(
930
+ [data-orion-part='data-table'][data-modifier='card'] *
931
+ ) {
851
932
  border: 0;
852
933
  background: transparent;
853
934
  outline-offset: 2px;
854
935
  }
855
936
 
856
937
  :root:has(.exp-scope[data-exp-tables='cards'])
857
- [data-orion-part='table-row']
938
+ [data-orion-part='table-row']:not(
939
+ [data-orion-part='data-table'][data-modifier='card'] *
940
+ )
858
941
  > td {
859
942
  padding: 14px 16px;
860
943
  border-top: 1px solid var(--exp-row-edge);
@@ -868,21 +951,27 @@
868
951
  }
869
952
 
870
953
  :root:has(.exp-scope[data-exp-tables='cards'])
871
- [data-orion-part='table-row']
954
+ [data-orion-part='table-row']:not(
955
+ [data-orion-part='data-table'][data-modifier='card'] *
956
+ )
872
957
  > td:first-child {
873
958
  border-left: 1px solid var(--exp-row-edge);
874
959
  border-radius: 14px 0 0 14px;
875
960
  }
876
961
 
877
962
  :root:has(.exp-scope[data-exp-tables='cards'])
878
- [data-orion-part='table-row']
963
+ [data-orion-part='table-row']:not(
964
+ [data-orion-part='data-table'][data-modifier='card'] *
965
+ )
879
966
  > td:last-child {
880
967
  border-right: 1px solid var(--exp-row-edge);
881
968
  border-radius: 0 14px 14px 0;
882
969
  }
883
970
 
884
971
  :root:has(.exp-scope[data-exp-tables='cards'])
885
- [data-orion-part='table-row']:is(:hover, :focus-visible, [data-selected])
972
+ [data-orion-part='table-row']:not(
973
+ [data-orion-part='data-table'][data-modifier='card'] *
974
+ ):is(:hover, :focus-visible, [data-selected])
886
975
  > td {
887
976
  border-color: var(--exp-row-edge-hover);
888
977
  background: var(--exp-row-bg-hover);
@@ -916,6 +1005,14 @@
916
1005
  transform: translateY(-2px);
917
1006
  }
918
1007
 
1008
+ .exp-search[data-state='idle']:is(:hover, :focus-within) {
1009
+ transform: translateY(-2px);
1010
+ }
1011
+
1012
+ .exp-search[data-state='idle']:is(:hover, :focus-within) .exp-search-lens {
1013
+ transform: rotate(-8deg) scale(1.06);
1014
+ }
1015
+
919
1016
  :root:has(.exp-scope)
920
1017
  button[data-orion-part='field-control']:not(:disabled):is(
921
1018
  :hover,
@@ -927,7 +1024,9 @@
927
1024
  }
928
1025
 
929
1026
  :root:has(.exp-scope[data-exp-tables='cards'])
930
- [data-orion-part='table-row']:is(:hover, :focus-visible, [data-selected])
1027
+ [data-orion-part='table-row']:not(
1028
+ [data-orion-part='data-table'][data-modifier='card'] *
1029
+ ):is(:hover, :focus-visible, [data-selected])
931
1030
  > td {
932
1031
  transform: translateY(-2px);
933
1032
  }
@@ -954,6 +1053,8 @@
954
1053
  .exp-rotor,
955
1054
  .exp-enter-arrow,
956
1055
  .exp-arrow,
1056
+ .exp-search,
1057
+ .exp-search-lens,
957
1058
  :root:has(.exp-scope) [data-orion-part='field-box'],
958
1059
  :root:has(.exp-scope) textarea[data-orion-part='field-control'],
959
1060
  :root:has(.exp-scope) [data-orion-part='field-icon'],
@@ -1,11 +1,15 @@
1
1
  import { type HTMLAttributes, type ReactNode } from 'react';
2
+ import { type TImageViewerDocumentLabels } from './ImageViewerDocument';
2
3
  import { type TImageCrop } from './ImageViewerImage';
3
4
  export type { TImageCrop } from './ImageViewerImage';
5
+ export type { TImageViewerDocumentLabels } from './ImageViewerDocument';
6
+ export type TImageViewType = 'image' | 'pdf';
4
7
  export type TImageView = {
5
8
  id: string;
6
9
  label: string;
7
10
  src: string;
8
11
  alt: string;
12
+ type?: TImageViewType;
9
13
  indicator?: ReactNode;
10
14
  crop?: TImageCrop;
11
15
  };
@@ -16,7 +20,7 @@ export type TImageViewerLabels = {
16
20
  rotate: string;
17
21
  enterFullscreen: string;
18
22
  exitFullscreen: string;
19
- };
23
+ } & TImageViewerDocumentLabels;
20
24
  type ImageViewerProps = {
21
25
  views: TImageView[];
22
26
  labels: TImageViewerLabels;
@@ -34,6 +38,12 @@ type ImageViewerProps = {
34
38
  * changes, whether the user picked a tab or the consumer moved `activeId`: a
35
39
  * transform that made sense for one angle rarely makes sense for the next.
36
40
  *
41
+ * Once zoomed in, the image can be panned: by dragging it with the pointer
42
+ * (mouse, touch or pen) or with the arrow keys while the panel has focus. The
43
+ * pan never leaves a gap between the image and the frame; zooming in keeps the
44
+ * point under inspection centered, zooming out to the minimum, rotating and
45
+ * toggling full screen center the image again.
46
+ *
37
47
  * Uncontrolled by default. Passing `activeId` makes the consumer own the active
38
48
  * view, so something outside the viewer (a side panel, a "next pending" action)
39
49
  * can follow or drive it; `onViewChange` reports tab picks in both modes.
@@ -49,6 +59,14 @@ type ImageViewerProps = {
49
59
  * A view's `crop` draws only that region of its image, fitted to the frame
50
60
  * like the whole image would be — a face inside a document, for instance.
51
61
  *
62
+ * A view with `type: 'pdf'` is rendered page by page into images with pdf.js,
63
+ * loaded on first use, so zoom, rotation and pan work on it exactly as on a
64
+ * photo and there is a single toolbar. A document with several pages shows a
65
+ * pager over the page; the toolbar gains an "open in a new tab" action for
66
+ * the original file. The browser fetches the PDF itself, so its origin must
67
+ * allow CORS; when it cannot be loaded, the view shows its description and
68
+ * the same link.
69
+ *
52
70
  * @component
53
71
  * @example
54
72
  * const [viewId, setViewId] = useState('front');
@@ -94,9 +112,23 @@ type ImageViewerProps = {
94
112
  * labels={labels}
95
113
  * />
96
114
  *
115
+ * @example
116
+ * <ImageViewer
117
+ * title="Insurance policy"
118
+ * views={[
119
+ * { id: 'policy', label: 'Policy', src: '/policy.pdf', alt: 'Insurance policy', type: 'pdf' },
120
+ * ]}
121
+ * labels={{
122
+ * ...labels,
123
+ * openDocument: 'Open PDF',
124
+ * previousPage: 'Previous page',
125
+ * nextPage: 'Next page',
126
+ * }}
127
+ * />
128
+ *
97
129
  * @param {Object} props - The properties of the ImageViewer component.
98
- * @param {TImageView[]} props.views - Images to display. In `tabs`, more than one renders a tab per view; in `split`, a pane per view. A view's optional `indicator` sits after its label and becomes part of the tab's accessible name, so give it its own (for an icon, `role="img"` and `aria-label`). In `tabs` with a single view there are no tabs, so no indicator is shown. A view's optional `crop` (`left`, `top`, `width`, `height` as fractions from 0 to 1 of the image) shows only that region; a crop outside the image is clamped to it, and one with no area left falls back to the whole image.
99
- * @param {TImageViewerLabels} props.labels - Accessible names of the toolbar actions.
130
+ * @param {TImageView[]} props.views - Files to display. In `tabs`, more than one renders a tab per view; in `split`, a pane per view. A view's optional `indicator` sits after its label and becomes part of the tab's accessible name, so give it its own (for an icon, `role="img"` and `aria-label`). In `tabs` with a single view there are no tabs, so no indicator is shown. A view's optional `crop` (`left`, `top`, `width`, `height` as fractions from 0 to 1 of the image) shows only that region; a crop outside the image is clamped to it, and one with no area left falls back to the whole image. A view's `type` is `image` unless set to `pdf`; `crop` is ignored on a `pdf`.
131
+ * @param {TImageViewerLabels} props.labels - Accessible names of the toolbar actions. With `pdf` views, `openDocument` names the action that opens the original file in a new tab, and `previousPage` / `nextPage` name the pager of a document with several pages; without the pair, only the first page is reachable.
100
132
  * @param {TImageViewerLayout} [props.layout='tabs'] - `tabs` shows one view at a time; `split` shows every view at once, one pane each.
101
133
  * @param {ReactNode} [props.title] - Optional heading rendered on the left of the toolbar.
102
134
  * @param {ReactNode} [props.emptyState] - Content rendered when there is no image.
@@ -1 +1 @@
1
- {"version":3,"file":"ImageViewer.d.ts","sourceRoot":"","sources":["../../../../src/components/02-molecules/ImageViewer/ImageViewer.tsx"],"names":[],"mappings":"AAGA,OAAO,EAIL,KAAK,cAAc,EACnB,KAAK,SAAS,EACf,MAAM,OAAO,CAAC;AAYf,OAAO,EAAoB,KAAK,UAAU,EAAE,MAAM,oBAAoB,CAAC;AAEvE,YAAY,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;AAErD,MAAM,MAAM,UAAU,GAAG;IACvB,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;IACZ,SAAS,CAAC,EAAE,SAAS,CAAC;IACtB,IAAI,CAAC,EAAE,UAAU,CAAC;CACnB,CAAC;AAEF,MAAM,MAAM,kBAAkB,GAAG,MAAM,GAAG,OAAO,CAAC;AAElD,MAAM,MAAM,kBAAkB,GAAG;IAC/B,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;IACf,eAAe,EAAE,MAAM,CAAC;IACxB,cAAc,EAAE,MAAM,CAAC;CACxB,CAAC;AAEF,KAAK,gBAAgB,GAAG;IACtB,KAAK,EAAE,UAAU,EAAE,CAAC;IACpB,MAAM,EAAE,kBAAkB,CAAC;IAC3B,MAAM,CAAC,EAAE,kBAAkB,CAAC;IAC5B,KAAK,CAAC,EAAE,SAAS,CAAC;IAClB,UAAU,CAAC,EAAE,SAAS,CAAC;IACvB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,YAAY,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAC;CACrC,GAAG,IAAI,CAAC,cAAc,CAAC,WAAW,CAAC,EAAE,UAAU,GAAG,OAAO,CAAC,CAAC;AAiB5D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+EG;AACH,wBAAgB,WAAW,CAAC,EAC1B,KAAK,EACL,MAAM,EACN,MAAe,EACf,KAAK,EACL,UAAU,EACV,QAAQ,EACR,YAAY,EACZ,GAAG,SAAS,EACb,EAAE,gBAAgB,GAAG,SAAS,CAqP9B"}
1
+ {"version":3,"file":"ImageViewer.d.ts","sourceRoot":"","sources":["../../../../src/components/02-molecules/ImageViewer/ImageViewer.tsx"],"names":[],"mappings":"AAGA,OAAO,EAKL,KAAK,cAAc,EAGnB,KAAK,SAAS,EACf,MAAM,OAAO,CAAC;AAaf,OAAO,EAEL,KAAK,0BAA0B,EAChC,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EAGL,KAAK,UAAU,EAChB,MAAM,oBAAoB,CAAC;AAE5B,YAAY,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;AACrD,YAAY,EAAE,0BAA0B,EAAE,MAAM,uBAAuB,CAAC;AAExE,MAAM,MAAM,cAAc,GAAG,OAAO,GAAG,KAAK,CAAC;AAE7C,MAAM,MAAM,UAAU,GAAG;IACvB,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,CAAC,EAAE,cAAc,CAAC;IACtB,SAAS,CAAC,EAAE,SAAS,CAAC;IACtB,IAAI,CAAC,EAAE,UAAU,CAAC;CACnB,CAAC;AAEF,MAAM,MAAM,kBAAkB,GAAG,MAAM,GAAG,OAAO,CAAC;AAElD,MAAM,MAAM,kBAAkB,GAAG;IAC/B,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;IACf,eAAe,EAAE,MAAM,CAAC;IACxB,cAAc,EAAE,MAAM,CAAC;CACxB,GAAG,0BAA0B,CAAC;AAE/B,KAAK,gBAAgB,GAAG;IACtB,KAAK,EAAE,UAAU,EAAE,CAAC;IACpB,MAAM,EAAE,kBAAkB,CAAC;IAC3B,MAAM,CAAC,EAAE,kBAAkB,CAAC;IAC5B,KAAK,CAAC,EAAE,SAAS,CAAC;IAClB,UAAU,CAAC,EAAE,SAAS,CAAC;IACvB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,YAAY,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAC;CACrC,GAAG,IAAI,CAAC,cAAc,CAAC,WAAW,CAAC,EAAE,UAAU,GAAG,OAAO,CAAC,CAAC;AA0E5D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2GG;AACH,wBAAgB,WAAW,CAAC,EAC1B,KAAK,EACL,MAAM,EACN,MAAe,EACf,KAAK,EACL,UAAU,EACV,QAAQ,EACR,YAAY,EACZ,GAAG,SAAS,EACb,EAAE,gBAAgB,GAAG,SAAS,CA6X9B"}
@@ -1,19 +1,57 @@
1
1
  'use client';
2
2
  import clsx from 'clsx';
3
- import { useEffect, useId, useState, } from 'react';
3
+ import { useEffect, useId, useRef, useState, } from 'react';
4
4
  import { createPortal } from 'react-dom';
5
- import { MdFullscreen, MdFullscreenExit, MdRotateRight, MdZoomIn, MdZoomOut, } from 'react-icons/md';
5
+ import { MdFullscreen, MdFullscreenExit, MdOpenInNew, MdRotateRight, MdZoomIn, MdZoomOut, } from 'react-icons/md';
6
6
  import { Each, Show } from '../../00-base';
7
7
  import { List, Paragraph } from '../../01-atoms';
8
8
  import { TabItem } from '../Tab/TabItem';
9
- import { ImageViewerImage } from './ImageViewerImage';
9
+ import { ImageViewerDocument, } from './ImageViewerDocument';
10
+ import { IMAGE_VIEWER_IMAGE_ATTRIBUTE, ImageViewerImage, } from './ImageViewerImage';
10
11
  const MIN_ZOOM = 1;
11
12
  const MAX_ZOOM = 3;
12
13
  const ZOOM_STEP = 0.5;
13
14
  const QUARTER_TURN = 90;
14
15
  const FULL_TURN = 360;
15
- const INITIAL_TRANSFORM = { zoom: MIN_ZOOM, rotation: 0 };
16
+ const KEYBOARD_PAN_STEP = 40;
17
+ const NO_OFFSET = { x: 0, y: 0 };
18
+ const INITIAL_TRANSFORM = {
19
+ zoom: MIN_ZOOM,
20
+ rotation: 0,
21
+ offset: NO_OFFSET,
22
+ };
23
+ /**
24
+ * Arrow keys move the viewport, so the image travels the opposite way, like
25
+ * scrolling.
26
+ */
27
+ const PAN_DIRECTIONS = {
28
+ ArrowLeft: { x: 1, y: 0 },
29
+ ArrowRight: { x: -1, y: 0 },
30
+ ArrowUp: { x: 0, y: 1 },
31
+ ArrowDown: { x: 0, y: -1 },
32
+ };
16
33
  const TOOL_CLASSNAME = 'hover:bg-gray-5 focus-visible:ring-lavender disabled:text-gray-3 dark:disabled:text-gray-1 dark:hover:bg-gray-1 flex h-8 w-8 cursor-pointer items-center justify-center rounded-sm text-lg text-black focus-visible:ring-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:hover:bg-transparent dark:text-white';
34
+ /**
35
+ * How far the images may leave the center before their edge enters the pane:
36
+ * half the overflow on each axis, none when the image already fits. Read from
37
+ * the rendered elements so it holds for any crop, rotation and zoom; `scale`
38
+ * anticipates a zoom that has not been painted yet. In `split`, the pane that
39
+ * overflows the most wins, so every image can be reached.
40
+ */
41
+ const measurePanBounds = (panel, scale = 1) => Array.from(panel.querySelectorAll(`[${IMAGE_VIEWER_IMAGE_ATTRIBUTE}]`)).reduce((bounds, image) => {
42
+ const pane = image.parentElement?.getBoundingClientRect();
43
+ if (!pane)
44
+ return bounds;
45
+ const { width, height } = image.getBoundingClientRect();
46
+ return {
47
+ x: Math.max(bounds.x, (width * scale - pane.width) / 2),
48
+ y: Math.max(bounds.y, (height * scale - pane.height) / 2),
49
+ };
50
+ }, NO_OFFSET);
51
+ const clampOffset = (offset, bounds) => ({
52
+ x: Math.min(Math.max(offset.x, -bounds.x), bounds.x),
53
+ y: Math.min(Math.max(offset.y, -bounds.y), bounds.y),
54
+ });
17
55
  /**
18
56
  * ImageViewer component for inspecting one or more images of the same subject.
19
57
  *
@@ -22,6 +60,12 @@ const TOOL_CLASSNAME = 'hover:bg-gray-5 focus-visible:ring-lavender disabled:tex
22
60
  * changes, whether the user picked a tab or the consumer moved `activeId`: a
23
61
  * transform that made sense for one angle rarely makes sense for the next.
24
62
  *
63
+ * Once zoomed in, the image can be panned: by dragging it with the pointer
64
+ * (mouse, touch or pen) or with the arrow keys while the panel has focus. The
65
+ * pan never leaves a gap between the image and the frame; zooming in keeps the
66
+ * point under inspection centered, zooming out to the minimum, rotating and
67
+ * toggling full screen center the image again.
68
+ *
25
69
  * Uncontrolled by default. Passing `activeId` makes the consumer own the active
26
70
  * view, so something outside the viewer (a side panel, a "next pending" action)
27
71
  * can follow or drive it; `onViewChange` reports tab picks in both modes.
@@ -37,6 +81,14 @@ const TOOL_CLASSNAME = 'hover:bg-gray-5 focus-visible:ring-lavender disabled:tex
37
81
  * A view's `crop` draws only that region of its image, fitted to the frame
38
82
  * like the whole image would be — a face inside a document, for instance.
39
83
  *
84
+ * A view with `type: 'pdf'` is rendered page by page into images with pdf.js,
85
+ * loaded on first use, so zoom, rotation and pan work on it exactly as on a
86
+ * photo and there is a single toolbar. A document with several pages shows a
87
+ * pager over the page; the toolbar gains an "open in a new tab" action for
88
+ * the original file. The browser fetches the PDF itself, so its origin must
89
+ * allow CORS; when it cannot be loaded, the view shows its description and
90
+ * the same link.
91
+ *
40
92
  * @component
41
93
  * @example
42
94
  * const [viewId, setViewId] = useState('front');
@@ -82,9 +134,23 @@ const TOOL_CLASSNAME = 'hover:bg-gray-5 focus-visible:ring-lavender disabled:tex
82
134
  * labels={labels}
83
135
  * />
84
136
  *
137
+ * @example
138
+ * <ImageViewer
139
+ * title="Insurance policy"
140
+ * views={[
141
+ * { id: 'policy', label: 'Policy', src: '/policy.pdf', alt: 'Insurance policy', type: 'pdf' },
142
+ * ]}
143
+ * labels={{
144
+ * ...labels,
145
+ * openDocument: 'Open PDF',
146
+ * previousPage: 'Previous page',
147
+ * nextPage: 'Next page',
148
+ * }}
149
+ * />
150
+ *
85
151
  * @param {Object} props - The properties of the ImageViewer component.
86
- * @param {TImageView[]} props.views - Images to display. In `tabs`, more than one renders a tab per view; in `split`, a pane per view. A view's optional `indicator` sits after its label and becomes part of the tab's accessible name, so give it its own (for an icon, `role="img"` and `aria-label`). In `tabs` with a single view there are no tabs, so no indicator is shown. A view's optional `crop` (`left`, `top`, `width`, `height` as fractions from 0 to 1 of the image) shows only that region; a crop outside the image is clamped to it, and one with no area left falls back to the whole image.
87
- * @param {TImageViewerLabels} props.labels - Accessible names of the toolbar actions.
152
+ * @param {TImageView[]} props.views - Files to display. In `tabs`, more than one renders a tab per view; in `split`, a pane per view. A view's optional `indicator` sits after its label and becomes part of the tab's accessible name, so give it its own (for an icon, `role="img"` and `aria-label`). In `tabs` with a single view there are no tabs, so no indicator is shown. A view's optional `crop` (`left`, `top`, `width`, `height` as fractions from 0 to 1 of the image) shows only that region; a crop outside the image is clamped to it, and one with no area left falls back to the whole image. A view's `type` is `image` unless set to `pdf`; `crop` is ignored on a `pdf`.
153
+ * @param {TImageViewerLabels} props.labels - Accessible names of the toolbar actions. With `pdf` views, `openDocument` names the action that opens the original file in a new tab, and `previousPage` / `nextPage` name the pager of a document with several pages; without the pair, only the first page is reachable.
88
154
  * @param {TImageViewerLayout} [props.layout='tabs'] - `tabs` shows one view at a time; `split` shows every view at once, one pane each.
89
155
  * @param {ReactNode} [props.title] - Optional heading rendered on the left of the toolbar.
90
156
  * @param {ReactNode} [props.emptyState] - Content rendered when there is no image.
@@ -98,6 +164,9 @@ export function ImageViewer({ views, labels, layout = 'tabs', title, emptyState,
98
164
  const [internalIndex, setInternalIndex] = useState(0);
99
165
  const [transform, setTransform] = useState(INITIAL_TRANSFORM);
100
166
  const [isFullscreen, setIsFullscreen] = useState(false);
167
+ const [isDragging, setIsDragging] = useState(false);
168
+ const panelRef = useRef(null);
169
+ const dragRef = useRef(null);
101
170
  const generatedId = useId();
102
171
  /**
103
172
  * Se sigue la firma de las vistas y no el arreglo: un consumidor que lo
@@ -106,6 +175,7 @@ export function ImageViewer({ views, labels, layout = 'tabs', title, emptyState,
106
175
  */
107
176
  const viewsSignature = views.map(({ id }) => id).join('|');
108
177
  useEffect(() => {
178
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- a different set of views starts again from the first one, without zoom or rotation
109
179
  setInternalIndex(0);
110
180
  setTransform(INITIAL_TRANSFORM);
111
181
  }, [viewsSignature]);
@@ -124,11 +194,17 @@ export function ImageViewer({ views, labels, layout = 'tabs', title, emptyState,
124
194
  ? Math.max(views.findIndex(({ id }) => id === activeId), 0)
125
195
  : internalIndex;
126
196
  const activeView = views[activeIndex] ?? views[0];
127
- const hasImage = Boolean(activeView);
197
+ const hasView = Boolean(activeView);
128
198
  const isSplit = layout === 'split';
129
199
  const hasTabs = !isSplit && views.length > 1;
130
- const { zoom, rotation } = transform;
131
- const imageStyle = { transform: `rotate(${rotation}deg) scale(${zoom})` };
200
+ const shownViews = isSplit ? views : activeView ? [activeView] : [];
201
+ const documentHref = !isSplit && activeView?.type === 'pdf' ? activeView.src : undefined;
202
+ const panelLabel = shownViews.map(({ alt }) => alt).join(', ') || undefined;
203
+ const { zoom, rotation, offset } = transform;
204
+ const canPan = hasView && zoom > MIN_ZOOM;
205
+ const imageStyle = {
206
+ transform: `translate(${offset.x}px, ${offset.y}px) rotate(${rotation}deg) scale(${zoom})`,
207
+ };
132
208
  /**
133
209
  * En modo controlado la vista puede cambiar sin pasar por `selectView`, asi
134
210
  * que el reinicio se ata a la vista activa y no al clic. Guardar el zoom por
@@ -149,6 +225,98 @@ export function ImageViewer({ views, labels, layout = 'tabs', title, emptyState,
149
225
  if (view.id !== activeView?.id)
150
226
  onViewChange?.(view.id);
151
227
  };
228
+ /**
229
+ * El desplazamiento se escala con el zoom para que el punto que se estaba
230
+ * mirando siga en el centro. Al acercar siempre cabe; al alejar puede quedar
231
+ * fuera del nuevo limite, asi que se acota midiendo con la escala destino.
232
+ */
233
+ const changeZoom = (delta) => {
234
+ const panel = panelRef.current;
235
+ setTransform((current) => {
236
+ const nextZoom = Math.min(Math.max(current.zoom + delta, MIN_ZOOM), MAX_ZOOM);
237
+ if (nextZoom === MIN_ZOOM) {
238
+ return { ...current, zoom: nextZoom, offset: NO_OFFSET };
239
+ }
240
+ const scale = nextZoom / current.zoom;
241
+ const scaled = {
242
+ x: current.offset.x * scale,
243
+ y: current.offset.y * scale,
244
+ };
245
+ return {
246
+ ...current,
247
+ zoom: nextZoom,
248
+ offset: panel
249
+ ? clampOffset(scaled, measurePanBounds(panel, scale))
250
+ : scaled,
251
+ };
252
+ });
253
+ };
254
+ const rotate = () => setTransform((current) => ({
255
+ ...current,
256
+ rotation: (current.rotation + QUARTER_TURN) % FULL_TURN,
257
+ offset: NO_OFFSET,
258
+ }));
259
+ const recenter = () => setTransform((current) => ({ ...current, offset: NO_OFFSET }));
260
+ const toggleFullscreen = () => {
261
+ setIsFullscreen((current) => !current);
262
+ recenter();
263
+ };
264
+ /**
265
+ * Los controles que viven sobre la imagen (el paginador de un PDF) deben
266
+ * recibir su clic: capturar el puntero aqui se lo quitaria.
267
+ */
268
+ const isControl = (target) => target instanceof Element && target.closest('button, a') !== null;
269
+ const startDrag = (event) => {
270
+ if (!canPan || event.button !== 0 || isControl(event.target))
271
+ return;
272
+ event.currentTarget.setPointerCapture(event.pointerId);
273
+ dragRef.current = {
274
+ pointerId: event.pointerId,
275
+ startX: event.clientX,
276
+ startY: event.clientY,
277
+ origin: offset,
278
+ bounds: measurePanBounds(event.currentTarget),
279
+ };
280
+ setIsDragging(true);
281
+ };
282
+ const moveDrag = (event) => {
283
+ const drag = dragRef.current;
284
+ if (drag?.pointerId !== event.pointerId)
285
+ return;
286
+ const next = clampOffset({
287
+ x: drag.origin.x + event.clientX - drag.startX,
288
+ y: drag.origin.y + event.clientY - drag.startY,
289
+ }, drag.bounds);
290
+ setTransform((current) => ({ ...current, offset: next }));
291
+ };
292
+ const endDrag = (event) => {
293
+ if (dragRef.current?.pointerId !== event.pointerId)
294
+ return;
295
+ dragRef.current = null;
296
+ setIsDragging(false);
297
+ };
298
+ const panWithKeyboard = (event) => {
299
+ const direction = PAN_DIRECTIONS[event.key];
300
+ if (!direction || !canPan)
301
+ return;
302
+ event.preventDefault();
303
+ const bounds = measurePanBounds(event.currentTarget);
304
+ setTransform((current) => ({
305
+ ...current,
306
+ offset: clampOffset({
307
+ x: current.offset.x + direction.x * KEYBOARD_PAN_STEP,
308
+ y: current.offset.y + direction.y * KEYBOARD_PAN_STEP,
309
+ }, bounds),
310
+ }));
311
+ };
312
+ const renderView = (view) => (<Show key={view.id}>
313
+ <Show.When isTrue={view.type === 'pdf'}>
314
+ <ImageViewerDocument src={view.src} alt={view.alt} style={imageStyle} isAnimated={!isDragging} labels={labels} onPageChange={recenter}/>
315
+ </Show.When>
316
+ <Show.Else>
317
+ <ImageViewerImage src={view.src} alt={view.alt} crop={view.crop} style={imageStyle} isAnimated={!isDragging}/>
318
+ </Show.Else>
319
+ </Show>);
152
320
  const frame = (<section {...restProps} className={clsx('border-gray-5 dark:border-gray-1 flex min-h-0 flex-col overflow-hidden rounded-lg border', isFullscreen ? 'h-full w-full bg-white dark:bg-black' : undefined, !isFullscreen ? restProps.className : undefined)}>
153
321
  <div className="border-gray-5 bg-gray-6 dark:border-gray-1 dark:bg-black-1 flex flex-none items-center justify-between gap-4 border-b px-4 py-3">
154
322
  <Show>
@@ -159,25 +327,19 @@ export function ImageViewer({ views, labels, layout = 'tabs', title, emptyState,
159
327
  </Show.When>
160
328
  </Show>
161
329
  <div className="ml-auto flex items-center gap-1.5">
162
- <button type="button" aria-label={labels.zoomOut} disabled={!hasImage || zoom <= MIN_ZOOM} onClick={() => setTransform((current) => ({
163
- ...current,
164
- zoom: current.zoom - ZOOM_STEP,
165
- }))} className={TOOL_CLASSNAME}>
330
+ {!!documentHref && !!labels.openDocument && (<a href={documentHref} target="_blank" rel="noopener noreferrer" aria-label={labels.openDocument} className={TOOL_CLASSNAME}>
331
+ <MdOpenInNew aria-hidden="true"/>
332
+ </a>)}
333
+ <button type="button" aria-label={labels.zoomOut} disabled={!hasView || zoom <= MIN_ZOOM} onClick={() => changeZoom(-ZOOM_STEP)} className={TOOL_CLASSNAME}>
166
334
  <MdZoomOut aria-hidden="true"/>
167
335
  </button>
168
- <button type="button" aria-label={labels.zoomIn} disabled={!hasImage || zoom >= MAX_ZOOM} onClick={() => setTransform((current) => ({
169
- ...current,
170
- zoom: current.zoom + ZOOM_STEP,
171
- }))} className={TOOL_CLASSNAME}>
336
+ <button type="button" aria-label={labels.zoomIn} disabled={!hasView || zoom >= MAX_ZOOM} onClick={() => changeZoom(ZOOM_STEP)} className={TOOL_CLASSNAME}>
172
337
  <MdZoomIn aria-hidden="true"/>
173
338
  </button>
174
- <button type="button" aria-label={labels.rotate} disabled={!hasImage} onClick={() => setTransform((current) => ({
175
- ...current,
176
- rotation: (current.rotation + QUARTER_TURN) % FULL_TURN,
177
- }))} className={TOOL_CLASSNAME}>
339
+ <button type="button" aria-label={labels.rotate} disabled={!hasView} onClick={rotate} className={TOOL_CLASSNAME}>
178
340
  <MdRotateRight aria-hidden="true"/>
179
341
  </button>
180
- <button type="button" aria-label={isFullscreen ? labels.exitFullscreen : labels.enterFullscreen} aria-pressed={isFullscreen} disabled={!hasImage} onClick={() => setIsFullscreen((current) => !current)} className={TOOL_CLASSNAME}>
342
+ <button type="button" aria-label={isFullscreen ? labels.exitFullscreen : labels.enterFullscreen} aria-pressed={isFullscreen} disabled={!hasView} onClick={toggleFullscreen} className={TOOL_CLASSNAME}>
181
343
  {isFullscreen ? (<MdFullscreenExit aria-hidden="true"/>) : (<MdFullscreen aria-hidden="true"/>)}
182
344
  </button>
183
345
  </div>
@@ -200,27 +362,27 @@ export function ImageViewer({ views, labels, layout = 'tabs', title, emptyState,
200
362
  </Show.When>
201
363
  </Show>
202
364
 
203
- <div id={`${generatedId}-panel`} role={hasTabs ? 'tabpanel' : undefined} aria-labelledby={hasTabs ? `${generatedId}-tab-${activeIndex}` : undefined} className="bg-gray-7 dark:bg-black-2 flex min-h-0 flex-1 items-center justify-center overflow-hidden p-5">
365
+ <div ref={panelRef} id={`${generatedId}-panel`} role={hasTabs ? 'tabpanel' : 'group'} aria-labelledby={hasTabs ? `${generatedId}-tab-${activeIndex}` : undefined} aria-label={hasTabs ? undefined : panelLabel} tabIndex={canPan ? 0 : undefined} onPointerDown={startDrag} onPointerMove={moveDrag} onPointerUp={endDrag} onPointerCancel={endDrag} onKeyDown={panWithKeyboard} className={clsx('bg-gray-7 dark:bg-black-2 focus-visible:ring-lavender flex min-h-0 flex-1 items-center justify-center overflow-hidden p-5 focus-visible:ring-2 focus-visible:outline-none focus-visible:ring-inset', canPan && 'touch-none select-none', canPan && (isDragging ? 'cursor-grabbing' : 'cursor-grab'))}>
204
366
  <Show>
205
- <Show.When isTrue={!!hasImage && isSplit}>
367
+ <Show.When isTrue={!!hasView && isSplit}>
206
368
  <div className="grid h-full w-full auto-rows-fr grid-cols-1 gap-5 sm:auto-cols-fr sm:grid-flow-col sm:grid-cols-none sm:grid-rows-1">
207
- <Each of={views} render={({ id, label, indicator, src, alt, crop }) => (<figure key={id} className="flex min-h-0 min-w-0 flex-col gap-2">
369
+ <Each of={views} render={(view) => (<figure key={view.id} className="flex min-h-0 min-w-0 flex-col gap-2">
208
370
  <figcaption className="flex flex-none items-center gap-1.5">
209
371
  <Paragraph size="subtitle-2" className="dark:text-white">
210
- {label}
372
+ {view.label}
211
373
  </Paragraph>
212
- {!!indicator && (<span className="flex flex-none items-center">
213
- {indicator}
374
+ {!!view.indicator && (<span className="flex flex-none items-center">
375
+ {view.indicator}
214
376
  </span>)}
215
377
  </figcaption>
216
378
  <div className="flex min-h-0 flex-1 items-center justify-center overflow-hidden">
217
- <ImageViewerImage src={src} alt={alt} crop={crop} style={imageStyle}/>
379
+ {renderView(view)}
218
380
  </div>
219
381
  </figure>)}/>
220
382
  </div>
221
383
  </Show.When>
222
- <Show.When isTrue={hasImage}>
223
- {!!activeView && (<ImageViewerImage key={activeView.id} src={activeView.src} alt={activeView.alt} crop={activeView.crop} style={imageStyle}/>)}
384
+ <Show.When isTrue={hasView}>
385
+ {!!activeView && renderView(activeView)}
224
386
  </Show.When>
225
387
  <Show.Else>{emptyState}</Show.Else>
226
388
  </Show>