@wordpress/editor 14.41.0 → 14.41.1-next.v.202603102151.0

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 (207) hide show
  1. package/build/components/collab-sidebar/index.cjs +7 -4
  2. package/build/components/collab-sidebar/index.cjs.map +2 -2
  3. package/build/components/collab-sidebar/utils.cjs +13 -15
  4. package/build/components/collab-sidebar/utils.cjs.map +2 -2
  5. package/build/components/collaborators-overlay/avatar-iframe-styles.cjs +133 -0
  6. package/build/components/collaborators-overlay/avatar-iframe-styles.cjs.map +7 -0
  7. package/build/components/collaborators-overlay/collaborator-styles.cjs +38 -2
  8. package/build/components/collaborators-overlay/collaborator-styles.cjs.map +2 -2
  9. package/build/components/collaborators-overlay/overlay-iframe-styles.cjs +142 -0
  10. package/build/components/collaborators-overlay/overlay-iframe-styles.cjs.map +7 -0
  11. package/build/components/collaborators-overlay/overlay.cjs +59 -201
  12. package/build/components/collaborators-overlay/overlay.cjs.map +3 -3
  13. package/build/components/collaborators-overlay/use-block-highlighting.cjs +91 -42
  14. package/build/components/collaborators-overlay/use-block-highlighting.cjs.map +2 -2
  15. package/build/components/collaborators-overlay/use-debounced-recompute.cjs +49 -0
  16. package/build/components/collaborators-overlay/use-debounced-recompute.cjs.map +7 -0
  17. package/build/components/collaborators-overlay/use-render-cursors.cjs +49 -50
  18. package/build/components/collaborators-overlay/use-render-cursors.cjs.map +2 -2
  19. package/build/components/collaborators-presence/avatar/component.cjs +121 -0
  20. package/build/components/collaborators-presence/avatar/component.cjs.map +7 -0
  21. package/build/components/collaborators-presence/avatar/index.cjs +37 -0
  22. package/build/components/collaborators-presence/avatar/index.cjs.map +7 -0
  23. package/build/components/collaborators-presence/avatar/types.cjs +19 -0
  24. package/build/components/collaborators-presence/avatar/types.cjs.map +7 -0
  25. package/build/components/collaborators-presence/avatar/use-image-loading-status.cjs +44 -0
  26. package/build/components/collaborators-presence/avatar/use-image-loading-status.cjs.map +7 -0
  27. package/build/components/collaborators-presence/avatar-group/component.cjs +78 -0
  28. package/build/components/collaborators-presence/avatar-group/component.cjs.map +7 -0
  29. package/build/components/collaborators-presence/avatar-group/index.cjs +37 -0
  30. package/build/components/collaborators-presence/avatar-group/index.cjs.map +7 -0
  31. package/build/components/collaborators-presence/avatar-group/types.cjs +19 -0
  32. package/build/components/collaborators-presence/avatar-group/types.cjs.map +7 -0
  33. package/build/components/collaborators-presence/index.cjs +17 -6
  34. package/build/components/collaborators-presence/index.cjs.map +3 -3
  35. package/build/components/collaborators-presence/list.cjs +20 -17
  36. package/build/components/collaborators-presence/list.cjs.map +3 -3
  37. package/build/components/entities-saved-states/hooks/use-is-dirty.cjs +14 -5
  38. package/build/components/entities-saved-states/hooks/use-is-dirty.cjs.map +2 -2
  39. package/build/components/global-styles/index.cjs +15 -24
  40. package/build/components/global-styles/index.cjs.map +3 -3
  41. package/build/components/global-styles-sidebar/index.cjs +6 -3
  42. package/build/components/global-styles-sidebar/index.cjs.map +2 -2
  43. package/build/components/page-attributes/parent.cjs +1 -0
  44. package/build/components/page-attributes/parent.cjs.map +2 -2
  45. package/build/components/post-revisions-preview/revisions-canvas.cjs +17 -4
  46. package/build/components/post-revisions-preview/revisions-canvas.cjs.map +2 -2
  47. package/build/components/post-url/panel.cjs +1 -0
  48. package/build/components/post-url/panel.cjs.map +2 -2
  49. package/build/components/provider/use-block-editor-settings.cjs +4 -1
  50. package/build/components/provider/use-block-editor-settings.cjs.map +3 -3
  51. package/build/components/sidebar/dataform-post-summary.cjs +167 -0
  52. package/build/components/sidebar/dataform-post-summary.cjs.map +7 -0
  53. package/build/components/sidebar/post-summary.cjs +11 -0
  54. package/build/components/sidebar/post-summary.cjs.map +3 -3
  55. package/build/components/visual-editor/index.cjs +1 -1
  56. package/build/components/visual-editor/index.cjs.map +2 -2
  57. package/build/dataviews/store/private-actions.cjs +4 -0
  58. package/build/dataviews/store/private-actions.cjs.map +2 -2
  59. package/build/utils/media-upload/on-success.cjs +46 -0
  60. package/build/utils/media-upload/on-success.cjs.map +7 -0
  61. package/build-module/components/collab-sidebar/index.mjs +7 -4
  62. package/build-module/components/collab-sidebar/index.mjs.map +2 -2
  63. package/build-module/components/collab-sidebar/utils.mjs +13 -15
  64. package/build-module/components/collab-sidebar/utils.mjs.map +2 -2
  65. package/build-module/components/collaborators-overlay/avatar-iframe-styles.mjs +120 -0
  66. package/build-module/components/collaborators-overlay/avatar-iframe-styles.mjs.map +7 -0
  67. package/build-module/components/collaborators-overlay/collaborator-styles.mjs +25 -1
  68. package/build-module/components/collaborators-overlay/collaborator-styles.mjs.map +2 -2
  69. package/build-module/components/collaborators-overlay/overlay-iframe-styles.mjs +124 -0
  70. package/build-module/components/collaborators-overlay/overlay-iframe-styles.mjs.map +7 -0
  71. package/build-module/components/collaborators-overlay/overlay.mjs +49 -201
  72. package/build-module/components/collaborators-overlay/overlay.mjs.map +2 -2
  73. package/build-module/components/collaborators-overlay/use-block-highlighting.mjs +92 -43
  74. package/build-module/components/collaborators-overlay/use-block-highlighting.mjs.map +2 -2
  75. package/build-module/components/collaborators-overlay/use-debounced-recompute.mjs +24 -0
  76. package/build-module/components/collaborators-overlay/use-debounced-recompute.mjs.map +7 -0
  77. package/build-module/components/collaborators-overlay/use-render-cursors.mjs +50 -51
  78. package/build-module/components/collaborators-overlay/use-render-cursors.mjs.map +2 -2
  79. package/build-module/components/collaborators-presence/avatar/component.mjs +90 -0
  80. package/build-module/components/collaborators-presence/avatar/component.mjs.map +7 -0
  81. package/build-module/components/collaborators-presence/avatar/index.mjs +6 -0
  82. package/build-module/components/collaborators-presence/avatar/index.mjs.map +7 -0
  83. package/build-module/components/collaborators-presence/avatar/types.mjs +1 -0
  84. package/build-module/components/collaborators-presence/avatar/types.mjs.map +7 -0
  85. package/build-module/components/collaborators-presence/avatar/use-image-loading-status.mjs +19 -0
  86. package/build-module/components/collaborators-presence/avatar/use-image-loading-status.mjs.map +7 -0
  87. package/build-module/components/collaborators-presence/avatar-group/component.mjs +47 -0
  88. package/build-module/components/collaborators-presence/avatar-group/component.mjs.map +7 -0
  89. package/build-module/components/collaborators-presence/avatar-group/index.mjs +6 -0
  90. package/build-module/components/collaborators-presence/avatar-group/index.mjs.map +7 -0
  91. package/build-module/components/collaborators-presence/avatar-group/types.mjs +1 -0
  92. package/build-module/components/collaborators-presence/avatar-group/types.mjs.map +7 -0
  93. package/build-module/components/collaborators-presence/index.mjs +7 -9
  94. package/build-module/components/collaborators-presence/index.mjs.map +2 -2
  95. package/build-module/components/collaborators-presence/list.mjs +11 -22
  96. package/build-module/components/collaborators-presence/list.mjs.map +2 -2
  97. package/build-module/components/entities-saved-states/hooks/use-is-dirty.mjs +14 -5
  98. package/build-module/components/entities-saved-states/hooks/use-is-dirty.mjs.map +2 -2
  99. package/build-module/components/global-styles/index.mjs +15 -24
  100. package/build-module/components/global-styles/index.mjs.map +2 -2
  101. package/build-module/components/global-styles-sidebar/index.mjs +6 -3
  102. package/build-module/components/global-styles-sidebar/index.mjs.map +2 -2
  103. package/build-module/components/page-attributes/parent.mjs +1 -0
  104. package/build-module/components/page-attributes/parent.mjs.map +2 -2
  105. package/build-module/components/post-revisions-preview/revisions-canvas.mjs +17 -4
  106. package/build-module/components/post-revisions-preview/revisions-canvas.mjs.map +2 -2
  107. package/build-module/components/post-url/panel.mjs +1 -0
  108. package/build-module/components/post-url/panel.mjs.map +2 -2
  109. package/build-module/components/provider/use-block-editor-settings.mjs +4 -1
  110. package/build-module/components/provider/use-block-editor-settings.mjs.map +2 -2
  111. package/build-module/components/sidebar/dataform-post-summary.mjs +136 -0
  112. package/build-module/components/sidebar/dataform-post-summary.mjs.map +7 -0
  113. package/build-module/components/sidebar/post-summary.mjs +11 -0
  114. package/build-module/components/sidebar/post-summary.mjs.map +2 -2
  115. package/build-module/components/visual-editor/index.mjs +1 -1
  116. package/build-module/components/visual-editor/index.mjs.map +2 -2
  117. package/build-module/dataviews/store/private-actions.mjs +8 -1
  118. package/build-module/dataviews/store/private-actions.mjs.map +2 -2
  119. package/build-module/utils/media-upload/on-success.mjs +25 -0
  120. package/build-module/utils/media-upload/on-success.mjs.map +7 -0
  121. package/build-style/style-rtl.css +876 -137
  122. package/build-style/style.css +876 -137
  123. package/build-types/components/collab-sidebar/index.d.ts.map +1 -1
  124. package/build-types/components/collab-sidebar/utils.d.ts.map +1 -1
  125. package/build-types/components/collaborators-overlay/avatar-iframe-styles.d.ts +11 -0
  126. package/build-types/components/collaborators-overlay/avatar-iframe-styles.d.ts.map +1 -0
  127. package/build-types/components/collaborators-overlay/collaborator-styles.d.ts +17 -2
  128. package/build-types/components/collaborators-overlay/collaborator-styles.d.ts.map +1 -1
  129. package/build-types/components/collaborators-overlay/overlay-iframe-styles.d.ts +6 -0
  130. package/build-types/components/collaborators-overlay/overlay-iframe-styles.d.ts.map +1 -0
  131. package/build-types/components/collaborators-overlay/overlay.d.ts.map +1 -1
  132. package/build-types/components/collaborators-overlay/use-block-highlighting.d.ts +21 -5
  133. package/build-types/components/collaborators-overlay/use-block-highlighting.d.ts.map +1 -1
  134. package/build-types/components/collaborators-overlay/use-debounced-recompute.d.ts +10 -0
  135. package/build-types/components/collaborators-overlay/use-debounced-recompute.d.ts.map +1 -0
  136. package/build-types/components/collaborators-overlay/use-render-cursors.d.ts +2 -1
  137. package/build-types/components/collaborators-overlay/use-render-cursors.d.ts.map +1 -1
  138. package/build-types/components/collaborators-presence/avatar/component.d.ts +7 -0
  139. package/build-types/components/collaborators-presence/avatar/component.d.ts.map +1 -0
  140. package/build-types/components/collaborators-presence/avatar/index.d.ts +3 -0
  141. package/build-types/components/collaborators-presence/avatar/index.d.ts.map +1 -0
  142. package/build-types/components/collaborators-presence/avatar/types.d.ts +66 -0
  143. package/build-types/components/collaborators-presence/avatar/types.d.ts.map +1 -0
  144. package/build-types/components/collaborators-presence/avatar/use-image-loading-status.d.ts +17 -0
  145. package/build-types/components/collaborators-presence/avatar/use-image-loading-status.d.ts.map +1 -0
  146. package/build-types/components/collaborators-presence/avatar-group/component.d.ts +7 -0
  147. package/build-types/components/collaborators-presence/avatar-group/component.d.ts.map +1 -0
  148. package/build-types/components/collaborators-presence/avatar-group/index.d.ts +3 -0
  149. package/build-types/components/collaborators-presence/avatar-group/index.d.ts.map +1 -0
  150. package/build-types/components/collaborators-presence/avatar-group/types.d.ts +14 -0
  151. package/build-types/components/collaborators-presence/avatar-group/types.d.ts.map +1 -0
  152. package/build-types/components/collaborators-presence/index.d.ts.map +1 -1
  153. package/build-types/components/collaborators-presence/list.d.ts.map +1 -1
  154. package/build-types/components/entities-saved-states/hooks/use-is-dirty.d.ts.map +1 -1
  155. package/build-types/components/global-styles/index.d.ts +2 -1
  156. package/build-types/components/global-styles/index.d.ts.map +1 -1
  157. package/build-types/components/global-styles-sidebar/index.d.ts.map +1 -1
  158. package/build-types/components/page-attributes/parent.d.ts.map +1 -1
  159. package/build-types/components/post-author/hook.d.ts +1 -1
  160. package/build-types/components/post-revisions-preview/revisions-canvas.d.ts.map +1 -1
  161. package/build-types/components/provider/use-block-editor-settings.d.ts.map +1 -1
  162. package/build-types/components/sidebar/dataform-post-summary.d.ts +4 -0
  163. package/build-types/components/sidebar/dataform-post-summary.d.ts.map +1 -0
  164. package/build-types/components/sidebar/post-summary.d.ts.map +1 -1
  165. package/build-types/dataviews/store/private-actions.d.ts.map +1 -1
  166. package/build-types/utils/media-upload/on-success.d.ts +9 -0
  167. package/build-types/utils/media-upload/on-success.d.ts.map +1 -0
  168. package/package.json +45 -44
  169. package/src/components/collab-sidebar/index.js +7 -4
  170. package/src/components/collab-sidebar/utils.js +9 -10
  171. package/src/components/collaborators-overlay/avatar-iframe-styles.ts +126 -0
  172. package/src/components/collaborators-overlay/collaborator-styles.ts +43 -2
  173. package/src/components/collaborators-overlay/overlay-iframe-styles.ts +125 -0
  174. package/src/components/collaborators-overlay/overlay.tsx +54 -207
  175. package/src/components/collaborators-overlay/use-block-highlighting.ts +147 -64
  176. package/src/components/collaborators-overlay/use-debounced-recompute.ts +32 -0
  177. package/src/components/collaborators-overlay/use-render-cursors.ts +72 -66
  178. package/src/components/collaborators-presence/avatar/component.tsx +123 -0
  179. package/src/components/collaborators-presence/avatar/index.ts +2 -0
  180. package/src/components/collaborators-presence/avatar/styles.scss +168 -0
  181. package/src/components/collaborators-presence/avatar/test/index.tsx +389 -0
  182. package/src/components/collaborators-presence/avatar/types.ts +66 -0
  183. package/src/components/collaborators-presence/avatar/use-image-loading-status.ts +36 -0
  184. package/src/components/collaborators-presence/avatar-group/component.tsx +55 -0
  185. package/src/components/collaborators-presence/avatar-group/index.ts +2 -0
  186. package/src/components/collaborators-presence/avatar-group/styles.scss +33 -0
  187. package/src/components/collaborators-presence/avatar-group/test/index.tsx +139 -0
  188. package/src/components/collaborators-presence/avatar-group/types.ts +13 -0
  189. package/src/components/collaborators-presence/index.tsx +4 -6
  190. package/src/components/collaborators-presence/list.tsx +7 -17
  191. package/src/components/collaborators-presence/styles/collaborators-list.scss +26 -19
  192. package/src/components/collaborators-presence/styles/collaborators-presence.scss +6 -2
  193. package/src/components/entities-saved-states/hooks/use-is-dirty.js +14 -5
  194. package/src/components/global-styles/index.js +20 -27
  195. package/src/components/global-styles-sidebar/index.js +3 -0
  196. package/src/components/page-attributes/parent.js +1 -0
  197. package/src/components/post-publish-panel/test/__snapshots__/index.js.snap +2 -2
  198. package/src/components/post-revisions-preview/revisions-canvas.js +15 -6
  199. package/src/components/post-url/panel.js +1 -0
  200. package/src/components/post-url/style.scss +5 -0
  201. package/src/components/provider/use-block-editor-settings.js +5 -0
  202. package/src/components/sidebar/dataform-post-summary.js +149 -0
  203. package/src/components/sidebar/post-summary.js +15 -0
  204. package/src/components/visual-editor/index.js +1 -1
  205. package/src/dataviews/store/private-actions.ts +14 -0
  206. package/src/style.scss +3 -0
  207. package/src/utils/media-upload/on-success.js +34 -0
@@ -0,0 +1,168 @@
1
+ @use "@wordpress/base-styles/colors" as *;
2
+ @use "@wordpress/base-styles/variables" as *;
3
+
4
+ // Accent color for the avatar. Uses the admin theme color with a hardcoded
5
+ // fallback matching the default WordPress admin blue.
6
+ $-accent-color: var(--wp-admin-theme-color, #3858e9);
7
+
8
+ .editor-avatar {
9
+ position: relative;
10
+ display: inline-flex;
11
+ align-items: center;
12
+ border-radius: $radius-full;
13
+ overflow: hidden;
14
+ overflow: clip;
15
+ flex-shrink: 0;
16
+ box-shadow: 0 0 0 var(--wp-admin-border-width-focus) $white, $elevation-x-small;
17
+ }
18
+
19
+ .editor-avatar__image {
20
+ box-sizing: border-box;
21
+ position: relative;
22
+ width: $button-size-compact;
23
+ height: $button-size-compact;
24
+ border-radius: $radius-full;
25
+ border: 0;
26
+ background-color: $-accent-color;
27
+ overflow: hidden;
28
+ overflow: clip;
29
+ flex-shrink: 0;
30
+ font-size: 0;
31
+ color: $white;
32
+
33
+ .is-small > & {
34
+ width: $button-size-small;
35
+ height: $button-size-small;
36
+ }
37
+
38
+ .has-avatar-border-color > & {
39
+ border: var(--wp-admin-border-width-focus) solid var(--editor-avatar-outline-color);
40
+ box-shadow: inset 0 0 0 var(--wp-admin-border-width-focus) $white;
41
+ background-clip: padding-box;
42
+ }
43
+ }
44
+
45
+ // The <img> element, absolutely positioned to fill the __image container.
46
+ // Starts invisible; becomes visible when the image loads (has-src class).
47
+ .editor-avatar__img {
48
+ position: absolute;
49
+ inset: 0;
50
+ width: 100%;
51
+ height: 100%;
52
+ object-fit: cover;
53
+ border-radius: inherit;
54
+ opacity: 0;
55
+
56
+ .has-src > .editor-avatar__image > & {
57
+ opacity: 1;
58
+ }
59
+ }
60
+
61
+ // Initials fallback: show name characters when no image has loaded.
62
+ .editor-avatar:not(.has-src) > .editor-avatar__image {
63
+ display: flex;
64
+ align-items: center;
65
+ justify-content: center;
66
+ font-size: $font-size-x-small;
67
+ font-weight: $font-weight-medium;
68
+ border: 0;
69
+ box-shadow: none;
70
+ background-clip: border-box;
71
+ }
72
+
73
+ .editor-avatar:not(.has-src).has-avatar-border-color > .editor-avatar__image {
74
+ background-color: var(--editor-avatar-outline-color);
75
+ }
76
+
77
+ .editor-avatar__name {
78
+ font-size: $font-size-medium;
79
+ font-weight: $font-weight-medium;
80
+ line-height: $font-line-height-small;
81
+ color: var(--editor-avatar-name-color, $white);
82
+ min-width: 0;
83
+ padding-bottom: calc($grid-unit-05 / 2);
84
+ overflow: hidden;
85
+ opacity: 0;
86
+ white-space: nowrap;
87
+ transition: opacity 0.15s cubic-bezier(0.15, 0, 0.15, 1);
88
+ }
89
+
90
+ // Badge mode: use grid so the name column animates from 0 to natural width.
91
+ // Spacing is on the container (column-gap + padding-inline-end) so it
92
+ // transitions alongside grid-template-columns instead of causing a bump.
93
+ .editor-avatar.is-badge {
94
+ display: inline-grid;
95
+ grid-template-columns: min-content 0fr;
96
+ column-gap: 0;
97
+ padding-inline-end: 0;
98
+ background-color: $-accent-color;
99
+ transition:
100
+ grid-template-columns 0.3s cubic-bezier(0.15, 0, 0.15, 1),
101
+ column-gap 0.3s cubic-bezier(0.15, 0, 0.15, 1),
102
+ padding-inline-end 0.3s cubic-bezier(0.15, 0, 0.15, 1);
103
+
104
+ &:hover {
105
+ grid-template-columns: min-content 1fr;
106
+ column-gap: $grid-unit-05;
107
+ padding-inline-end: $grid-unit-10;
108
+ transition-timing-function: cubic-bezier(0.85, 0, 0.85, 1);
109
+ }
110
+
111
+ &:hover .editor-avatar__name {
112
+ opacity: 1;
113
+ transition-timing-function: cubic-bezier(0.85, 0, 0.85, 1);
114
+ }
115
+ }
116
+
117
+ .editor-avatar.is-badge.has-avatar-border-color {
118
+ background-color: var(--editor-avatar-outline-color);
119
+ }
120
+
121
+ // Dimmed: neutral gray background at 50% opacity. The status indicator is a
122
+ // sibling (outside __image) so it stays at full opacity.
123
+ .editor-avatar.is-dimmed > .editor-avatar__image {
124
+ opacity: 0.5;
125
+ background-color: $gray-700;
126
+ }
127
+
128
+ .editor-avatar.is-dimmed.has-avatar-border-color > .editor-avatar__image {
129
+ border-color: $gray-700;
130
+ }
131
+
132
+ // Dimmed images: desaturate via CSS filter instead of mix-blend-mode.
133
+ .editor-avatar.is-dimmed .editor-avatar__img {
134
+ filter: grayscale(1);
135
+ }
136
+
137
+ // Status indicator: positioned over the image area, outside __image so it
138
+ // is unaffected by the dimmed opacity.
139
+ .editor-avatar__status-indicator {
140
+ position: absolute;
141
+ top: 0;
142
+ left: 0;
143
+ width: $button-size-compact;
144
+ height: $button-size-compact;
145
+ display: flex;
146
+ align-items: center;
147
+ justify-content: center;
148
+ z-index: 1;
149
+ color: $gray-900;
150
+ fill: $gray-900;
151
+
152
+ .is-small > & {
153
+ width: $button-size-small;
154
+ height: $button-size-small;
155
+ }
156
+
157
+ svg {
158
+ width: 75%;
159
+ height: 75%;
160
+ }
161
+ }
162
+
163
+ @media (prefers-reduced-motion: reduce) {
164
+ .editor-avatar.is-badge,
165
+ .editor-avatar__name {
166
+ transition: none;
167
+ }
168
+ }
@@ -0,0 +1,389 @@
1
+ /**
2
+ * External dependencies
3
+ */
4
+ import { render, screen, fireEvent } from '@testing-library/react';
5
+
6
+ /**
7
+ * Internal dependencies
8
+ */
9
+ import Avatar from '..';
10
+
11
+ /**
12
+ * In JSDOM, `<img>` elements never fire `load` or `error` events on their
13
+ * own. We simulate them using `fireEvent` on the `<img>` element, which we
14
+ * locate via `getByAltText('')` (the `<img>` has `alt=""`).
15
+ */
16
+
17
+ describe( 'Avatar', () => {
18
+ it( 'should render with default props', () => {
19
+ render( <Avatar data-testid="avatar" /> );
20
+ const avatar = screen.getByTestId( 'avatar' );
21
+ expect( avatar ).toBeInTheDocument();
22
+ expect( avatar.tagName ).toBe( 'DIV' );
23
+ expect( avatar ).toHaveClass( 'editor-avatar' );
24
+ } );
25
+
26
+ it( 'should set the accessible name from the name prop', () => {
27
+ render( <Avatar name="Jane Doe" /> );
28
+ const avatar = screen.getByRole( 'img', { name: 'Jane Doe' } );
29
+ expect( avatar ).toBeInTheDocument();
30
+ } );
31
+
32
+ it( 'should not set role or aria-label without a name', () => {
33
+ render( <Avatar data-testid="avatar" /> );
34
+ const avatar = screen.getByTestId( 'avatar' );
35
+ expect( avatar ).not.toHaveAttribute( 'role' );
36
+ expect( avatar ).not.toHaveAttribute( 'aria-label' );
37
+ } );
38
+
39
+ it( 'should render an img element when src is provided', () => {
40
+ render(
41
+ <Avatar
42
+ data-testid="avatar"
43
+ name="Jane Doe"
44
+ src="https://example.com/avatar.jpg"
45
+ />
46
+ );
47
+ // The <img> should be in the DOM (hidden until loaded).
48
+ const img = screen.getByAltText( '' );
49
+ expect( img.tagName ).toBe( 'IMG' );
50
+ expect( img ).toHaveAttribute(
51
+ 'src',
52
+ 'https://example.com/avatar.jpg'
53
+ );
54
+ } );
55
+
56
+ it( 'should apply has-src class only after image loads', () => {
57
+ render(
58
+ <Avatar
59
+ data-testid="avatar"
60
+ name="Jane Doe"
61
+ src="https://example.com/avatar.jpg"
62
+ />
63
+ );
64
+ const avatar = screen.getByTestId( 'avatar' );
65
+ // Before load fires, has-src should not be set.
66
+ expect( avatar ).not.toHaveClass( 'has-src' );
67
+
68
+ // Simulate image load.
69
+ fireEvent.load( screen.getByAltText( '' ) );
70
+ expect( avatar ).toHaveClass( 'has-src' );
71
+ } );
72
+
73
+ it( 'should apply is-small class for small size', () => {
74
+ render( <Avatar data-testid="avatar" size="small" /> );
75
+ const avatar = screen.getByTestId( 'avatar' );
76
+ expect( avatar ).toHaveClass( 'is-small' );
77
+ } );
78
+
79
+ it( 'should not apply is-small class for default size', () => {
80
+ render( <Avatar data-testid="avatar" /> );
81
+ const avatar = screen.getByTestId( 'avatar' );
82
+ expect( avatar ).not.toHaveClass( 'is-small' );
83
+ } );
84
+
85
+ it( 'should apply border color when provided', () => {
86
+ render( <Avatar data-testid="avatar" borderColor="#3858e9" /> );
87
+ const avatar = screen.getByTestId( 'avatar' );
88
+ expect( avatar ).toHaveClass( 'has-avatar-border-color' );
89
+ expect(
90
+ avatar.style.getPropertyValue( '--editor-avatar-outline-color' )
91
+ ).toBe( '#3858e9' );
92
+ } );
93
+
94
+ it( 'should set name color custom property when borderColor is provided', () => {
95
+ render( <Avatar data-testid="avatar" borderColor="#3858e9" /> );
96
+ const avatar = screen.getByTestId( 'avatar' );
97
+ expect(
98
+ avatar.style.getPropertyValue( '--editor-avatar-name-color' )
99
+ ).toBeTruthy();
100
+ } );
101
+
102
+ it( 'should not have has-src class when src is not provided', () => {
103
+ render( <Avatar data-testid="avatar" /> );
104
+ const avatar = screen.getByTestId( 'avatar' );
105
+ expect( avatar ).not.toHaveClass( 'has-src' );
106
+ } );
107
+
108
+ it( 'should combine custom className with default class', () => {
109
+ render( <Avatar data-testid="avatar" className="custom" /> );
110
+ const avatar = screen.getByTestId( 'avatar' );
111
+ expect( avatar ).toHaveClass( 'editor-avatar' );
112
+ expect( avatar ).toHaveClass( 'custom' );
113
+ } );
114
+
115
+ it( 'should pass through additional HTML attributes', () => {
116
+ render( <Avatar data-testid="avatar" data-custom="value" /> );
117
+ const avatar = screen.getByTestId( 'avatar' );
118
+ expect( avatar ).toHaveAttribute( 'data-custom', 'value' );
119
+ } );
120
+
121
+ it( 'should merge style prop with custom properties', () => {
122
+ render(
123
+ <Avatar
124
+ data-testid="avatar"
125
+ borderColor="#3858e9"
126
+ style={ { left: '10px' } }
127
+ />
128
+ );
129
+ const avatar = screen.getByTestId( 'avatar' );
130
+ expect( avatar ).toHaveStyle( { left: '10px' } );
131
+ expect(
132
+ avatar.style.getPropertyValue( '--editor-avatar-outline-color' )
133
+ ).toBe( '#3858e9' );
134
+ } );
135
+
136
+ describe( 'variant: badge', () => {
137
+ it( 'should not show badge by default', () => {
138
+ render( <Avatar data-testid="avatar" name="Zoraya" /> );
139
+ const avatar = screen.getByTestId( 'avatar' );
140
+ expect( avatar ).not.toHaveClass( 'is-badge' );
141
+ expect( screen.queryByText( 'Zoraya' ) ).not.toBeInTheDocument();
142
+ } );
143
+
144
+ it( 'should render name span with badge variant', () => {
145
+ render(
146
+ <Avatar data-testid="avatar" name="Zoraya" variant="badge" />
147
+ );
148
+ const avatar = screen.getByTestId( 'avatar' );
149
+ expect( avatar ).toHaveClass( 'is-badge' );
150
+ expect( screen.getByText( 'Zoraya' ) ).toBeInTheDocument();
151
+ } );
152
+
153
+ it( 'should render name span with borderColor too', () => {
154
+ render(
155
+ <Avatar
156
+ data-testid="avatar"
157
+ name="Zoraya"
158
+ borderColor="#3d5eef"
159
+ variant="badge"
160
+ />
161
+ );
162
+ const avatar = screen.getByTestId( 'avatar' );
163
+ expect( avatar ).toHaveClass( 'is-badge' );
164
+ expect( screen.getByText( 'Zoraya' ) ).toBeInTheDocument();
165
+ } );
166
+
167
+ it( 'should not show badge when name is missing', () => {
168
+ render( <Avatar data-testid="avatar" variant="badge" /> );
169
+ const avatar = screen.getByTestId( 'avatar' );
170
+ expect( avatar ).not.toHaveClass( 'is-badge' );
171
+ } );
172
+
173
+ it( 'should still set aria-label even when badge is visible', () => {
174
+ render( <Avatar name="Zoraya" variant="badge" /> );
175
+ const avatar = screen.getByRole( 'img', { name: 'Zoraya' } );
176
+ expect( avatar ).toBeInTheDocument();
177
+ } );
178
+ } );
179
+
180
+ describe( 'label', () => {
181
+ it( 'should show label text instead of name in the badge', () => {
182
+ render( <Avatar name="Jane Doe" label="You" variant="badge" /> );
183
+ expect( screen.getByText( 'You' ) ).toBeInTheDocument();
184
+ expect( screen.queryByText( 'Jane Doe' ) ).not.toBeInTheDocument();
185
+ } );
186
+
187
+ it( 'should keep aria-label as name when label is provided', () => {
188
+ render( <Avatar name="Jane Doe" label="You" variant="badge" /> );
189
+ const avatar = screen.getByRole( 'img', { name: 'Jane Doe' } );
190
+ expect( avatar ).toBeInTheDocument();
191
+ } );
192
+
193
+ it( 'should wrap in tooltip when label differs from name', () => {
194
+ render( <Avatar name="Jane Doe" label="You" variant="badge" /> );
195
+ const avatar = screen.getByRole( 'img', { name: 'Jane Doe' } );
196
+ // The Tooltip's Ariakit.TooltipAnchor makes the element
197
+ // focusable so the tooltip can be triggered via keyboard.
198
+ expect( avatar ).toHaveAttribute( 'tabindex', '0' );
199
+ } );
200
+ } );
201
+
202
+ describe( 'dimmed', () => {
203
+ it( 'should apply is-dimmed class when dimmed', () => {
204
+ render( <Avatar data-testid="avatar" dimmed /> );
205
+ const avatar = screen.getByTestId( 'avatar' );
206
+ expect( avatar ).toHaveClass( 'is-dimmed' );
207
+ } );
208
+
209
+ it( 'should not apply is-dimmed class by default', () => {
210
+ render( <Avatar data-testid="avatar" /> );
211
+ const avatar = screen.getByTestId( 'avatar' );
212
+ expect( avatar ).not.toHaveClass( 'is-dimmed' );
213
+ } );
214
+
215
+ it( 'should render statusIndicator when dimmed', () => {
216
+ render(
217
+ <Avatar
218
+ data-testid="avatar"
219
+ dimmed
220
+ statusIndicator={ <span>icon</span> }
221
+ />
222
+ );
223
+ expect( screen.getByText( 'icon' ) ).toBeInTheDocument();
224
+ } );
225
+
226
+ it( 'should not render statusIndicator when not dimmed', () => {
227
+ render(
228
+ <Avatar
229
+ data-testid="avatar"
230
+ statusIndicator={ <span>icon</span> }
231
+ />
232
+ );
233
+ expect( screen.queryByText( 'icon' ) ).not.toBeInTheDocument();
234
+ } );
235
+
236
+ it( 'should apply has-src class when dimmed after image loads', () => {
237
+ render(
238
+ <Avatar
239
+ data-testid="avatar"
240
+ src="https://example.com/avatar.jpg"
241
+ dimmed
242
+ />
243
+ );
244
+ fireEvent.load( screen.getByAltText( '' ) );
245
+ const avatar = screen.getByTestId( 'avatar' );
246
+ expect( avatar ).toHaveClass( 'has-src' );
247
+ expect( avatar ).toHaveClass( 'is-dimmed' );
248
+ } );
249
+ } );
250
+
251
+ describe( 'initials', () => {
252
+ it( 'should show initials when no src is provided', () => {
253
+ render( <Avatar name="Tanner Robinson" /> );
254
+ expect( screen.getByText( 'TR' ) ).toBeInTheDocument();
255
+ } );
256
+
257
+ it( 'should show single initial for single-word name', () => {
258
+ render( <Avatar name="Zoraya" /> );
259
+ expect( screen.getByText( 'Z' ) ).toBeInTheDocument();
260
+ } );
261
+
262
+ it( 'should limit initials to two characters', () => {
263
+ render( <Avatar name="Jane Marie Doe" /> );
264
+ expect( screen.getByText( 'JM' ) ).toBeInTheDocument();
265
+ } );
266
+
267
+ it( 'should uppercase initials', () => {
268
+ render( <Avatar name="jane doe" /> );
269
+ expect( screen.getByText( 'JD' ) ).toBeInTheDocument();
270
+ } );
271
+
272
+ it( 'should not show initials after image loads', () => {
273
+ render(
274
+ <Avatar
275
+ name="Tanner Robinson"
276
+ src="https://example.com/avatar.jpg"
277
+ />
278
+ );
279
+ fireEvent.load( screen.getByAltText( '' ) );
280
+ expect( screen.queryByText( 'TR' ) ).not.toBeInTheDocument();
281
+ } );
282
+
283
+ it( 'should not render initials when name is not provided', () => {
284
+ render( <Avatar data-testid="avatar" /> );
285
+ const avatar = screen.getByTestId( 'avatar' );
286
+ // Without a name, the image span should be empty (no initials).
287
+ expect( avatar ).not.toHaveTextContent( /.+/ );
288
+ } );
289
+ } );
290
+
291
+ describe( 'image loading', () => {
292
+ it( 'should reset to loading state when src changes', () => {
293
+ const { rerender } = render(
294
+ <Avatar
295
+ data-testid="avatar"
296
+ name="Jane Doe"
297
+ src="https://example.com/a.jpg"
298
+ />
299
+ );
300
+ fireEvent.load( screen.getByAltText( '' ) );
301
+ expect( screen.getByTestId( 'avatar' ) ).toHaveClass( 'has-src' );
302
+
303
+ rerender(
304
+ <Avatar
305
+ data-testid="avatar"
306
+ name="Jane Doe"
307
+ src="https://example.com/b.jpg"
308
+ />
309
+ );
310
+ // New src should reset to loading — initials visible again.
311
+ expect( screen.getByTestId( 'avatar' ) ).not.toHaveClass(
312
+ 'has-src'
313
+ );
314
+ expect( screen.getByText( 'JD' ) ).toBeInTheDocument();
315
+ } );
316
+
317
+ it( 'should show initials while image is loading', () => {
318
+ render(
319
+ <Avatar
320
+ data-testid="avatar"
321
+ name="Jane Doe"
322
+ src="https://example.com/avatar.jpg"
323
+ />
324
+ );
325
+ const avatar = screen.getByTestId( 'avatar' );
326
+ // Before load event, initials should show.
327
+ expect( avatar ).not.toHaveClass( 'has-src' );
328
+ expect( screen.getByText( 'JD' ) ).toBeInTheDocument();
329
+ } );
330
+
331
+ it( 'should show image after successful load', () => {
332
+ render(
333
+ <Avatar
334
+ data-testid="avatar"
335
+ name="Jane Doe"
336
+ src="https://example.com/avatar.jpg"
337
+ />
338
+ );
339
+
340
+ fireEvent.load( screen.getByAltText( '' ) );
341
+
342
+ const avatar = screen.getByTestId( 'avatar' );
343
+ expect( avatar ).toHaveClass( 'has-src' );
344
+ expect( screen.queryByText( 'JD' ) ).not.toBeInTheDocument();
345
+ } );
346
+
347
+ it( 'should fall back to initials when image fails to load', () => {
348
+ render(
349
+ <Avatar
350
+ data-testid="avatar"
351
+ name="Jane Doe"
352
+ src="https://example.com/bad.jpg"
353
+ />
354
+ );
355
+
356
+ fireEvent.error( screen.getByAltText( '' ) );
357
+
358
+ const avatar = screen.getByTestId( 'avatar' );
359
+ expect( avatar ).not.toHaveClass( 'has-src' );
360
+ expect( screen.getByText( 'JD' ) ).toBeInTheDocument();
361
+ } );
362
+
363
+ it( 'should not render img element when no src is provided', () => {
364
+ render( <Avatar data-testid="avatar" name="Jane Doe" /> );
365
+ expect( screen.queryByAltText( '' ) ).not.toBeInTheDocument();
366
+ } );
367
+ } );
368
+
369
+ describe( 'tooltip', () => {
370
+ it( 'should wrap in tooltip when name is provided without badge', () => {
371
+ render( <Avatar name="Jane Doe" /> );
372
+ const avatar = screen.getByRole( 'img', { name: 'Jane Doe' } );
373
+ expect( avatar ).toHaveAttribute( 'tabindex', '0' );
374
+ } );
375
+
376
+ it( 'should not wrap in tooltip for badge without label', () => {
377
+ render( <Avatar name="Jane Doe" variant="badge" /> );
378
+ const avatar = screen.getByRole( 'img', { name: 'Jane Doe' } );
379
+ // Badge shows the name visibly, so no tooltip needed.
380
+ expect( avatar ).not.toHaveAttribute( 'tabindex' );
381
+ } );
382
+
383
+ it( 'should not wrap in tooltip when name is not provided', () => {
384
+ render( <Avatar data-testid="avatar" /> );
385
+ const avatar = screen.getByTestId( 'avatar' );
386
+ expect( avatar ).not.toHaveAttribute( 'tabindex' );
387
+ } );
388
+ } );
389
+ } );
@@ -0,0 +1,66 @@
1
+ import type { IconType } from '@wordpress/components';
2
+
3
+ export type AvatarProps = {
4
+ /**
5
+ * URL of the avatar image.
6
+ *
7
+ * When not provided, initials derived from `name` are shown.
8
+ */
9
+ src?: string;
10
+ /**
11
+ * Name of the user. Used as an accessible label and to generate
12
+ * initials when no image is provided.
13
+ */
14
+ name?: string;
15
+ /**
16
+ * Visible text shown in the hover badge. When not provided, `name`
17
+ * is used instead. Use this to provide contextual labels like "You"
18
+ * without affecting the accessible name or initials.
19
+ */
20
+ label?: string;
21
+ /**
22
+ * Specifies the avatar's visual style treatment.
23
+ *
24
+ * - `'badge'`: Displays a hover-expand pill that reveals the user's
25
+ * name (or `label`) on hover. Requires `name` to be set.
26
+ *
27
+ * Leave undefined for the default circular avatar.
28
+ */
29
+ variant?: 'badge';
30
+ /**
31
+ * Size of the avatar.
32
+ *
33
+ * - `'default'`: For standalone avatars and list items where the
34
+ * avatar is a primary visual element (e.g. collaborator lists,
35
+ * user profiles).
36
+ * - `'small'`: For inline or compact contexts where space is
37
+ * limited (e.g. cursor labels, toolbars, badges alongside text).
38
+ *
39
+ * @default 'default'
40
+ */
41
+ size?: 'default' | 'small';
42
+ /**
43
+ * CSS color value for an accent border ring around the avatar.
44
+ *
45
+ * When not provided, no border is rendered and the hover badge
46
+ * uses the admin theme color as its background.
47
+ */
48
+ borderColor?: string;
49
+ /**
50
+ * Whether to dim the avatar to indicate an inactive or away state.
51
+ * When true, images are desaturated and faded, and initials are
52
+ * reduced in opacity.
53
+ *
54
+ * @default false
55
+ */
56
+ dimmed?: boolean;
57
+ /**
58
+ * An icon or custom component rendered as a centered overlay on the
59
+ * avatar image. Only visible when `dimmed` is true.
60
+ *
61
+ * Accepts any value supported by the `Icon` component: an icon from
62
+ * `@wordpress/icons`, a Dashicon name string, a component, or a
63
+ * JSX element.
64
+ */
65
+ statusIndicator?: IconType | null;
66
+ };
@@ -0,0 +1,36 @@
1
+ /**
2
+ * WordPress dependencies
3
+ */
4
+ import { useCallback, useState } from '@wordpress/element';
5
+
6
+ export type ImageLoadingStatus = 'idle' | 'loading' | 'loaded' | 'error';
7
+
8
+ /**
9
+ * Tracks the loading status of an image URL. Returns the current status and
10
+ * `onLoad`/`onError` callbacks to attach to the `<img>` element.
11
+ *
12
+ * Unlike a side-channel `new Image()` preloader, this hook relies on the
13
+ * native `<img>` element's own events, which avoids cross-browser issues
14
+ * with Safari's privacy features blocking programmatic image requests.
15
+ *
16
+ * @param src - The image URL. When falsy, status is `'idle'`.
17
+ */
18
+ export function useImageLoadingStatus( src?: string ) {
19
+ const [ prevSrc, setPrevSrc ] = useState( src );
20
+ const [ status, setStatus ] = useState< ImageLoadingStatus >(
21
+ src ? 'loading' : 'idle'
22
+ );
23
+
24
+ // Synchronous reset when src changes — runs during render, not after
25
+ // commit, so a cached image's `load` event cannot sneak in before
26
+ // the reset and get overwritten.
27
+ if ( prevSrc !== src ) {
28
+ setPrevSrc( src );
29
+ setStatus( src ? 'loading' : 'idle' );
30
+ }
31
+
32
+ const handleLoad = useCallback( () => setStatus( 'loaded' ), [] );
33
+ const handleError = useCallback( () => setStatus( 'error' ), [] );
34
+
35
+ return { status, handleLoad, handleError };
36
+ }