@stack-spot/citric-react 0.38.0 → 0.39.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (191) hide show
  1. package/CHANGELOG.md +13 -13
  2. package/dist/citric.css +2844 -2844
  3. package/dist/components/Accordion.d.ts +1 -1
  4. package/dist/components/Accordion.js +1 -1
  5. package/dist/components/Alert.d.ts +1 -1
  6. package/dist/components/Alert.js +1 -1
  7. package/dist/components/AsyncContent.d.ts +1 -1
  8. package/dist/components/AsyncContent.js +1 -1
  9. package/dist/components/Avatar.d.ts +1 -1
  10. package/dist/components/Avatar.js +1 -1
  11. package/dist/components/AvatarGroup.d.ts +1 -1
  12. package/dist/components/AvatarGroup.js +1 -1
  13. package/dist/components/Badge.d.ts +1 -1
  14. package/dist/components/Badge.js +1 -1
  15. package/dist/components/Blockquote.d.ts +1 -1
  16. package/dist/components/Blockquote.js +1 -1
  17. package/dist/components/Breadcrumb.d.ts +1 -1
  18. package/dist/components/Breadcrumb.js +1 -1
  19. package/dist/components/Button.d.ts +1 -1
  20. package/dist/components/Button.js +1 -1
  21. package/dist/components/ButtonLink.d.ts +1 -1
  22. package/dist/components/ButtonLink.js +1 -1
  23. package/dist/components/Card.d.ts +1 -1
  24. package/dist/components/Card.js +1 -1
  25. package/dist/components/Checkbox.d.ts +1 -1
  26. package/dist/components/Checkbox.js +1 -1
  27. package/dist/components/CheckboxGroup.d.ts +1 -1
  28. package/dist/components/CheckboxGroup.d.ts.map +1 -1
  29. package/dist/components/CheckboxGroup.js +2 -2
  30. package/dist/components/CheckboxGroup.js.map +1 -1
  31. package/dist/components/Circle.d.ts +1 -1
  32. package/dist/components/Circle.js +1 -1
  33. package/dist/components/Divider.d.ts +1 -1
  34. package/dist/components/Divider.js +1 -1
  35. package/dist/components/ErrorBoundary.d.ts +1 -1
  36. package/dist/components/ErrorBoundary.js +1 -1
  37. package/dist/components/ErrorMessage.d.ts +1 -1
  38. package/dist/components/ErrorMessage.js +1 -1
  39. package/dist/components/FallbackBoundary.d.ts +1 -1
  40. package/dist/components/FallbackBoundary.js +1 -1
  41. package/dist/components/Favorite.d.ts +1 -1
  42. package/dist/components/Favorite.js +1 -1
  43. package/dist/components/FieldGroup.d.ts +1 -1
  44. package/dist/components/FieldGroup.js +1 -1
  45. package/dist/components/Form.d.ts +2 -2
  46. package/dist/components/Form.js +1 -1
  47. package/dist/components/FormGroup.d.ts +1 -1
  48. package/dist/components/FormGroup.js +1 -1
  49. package/dist/components/Icon.d.ts +1 -1
  50. package/dist/components/Icon.js +1 -1
  51. package/dist/components/IconBox.d.ts +3 -3
  52. package/dist/components/IconBox.js +1 -1
  53. package/dist/components/ImageBox.d.ts +3 -3
  54. package/dist/components/ImageBox.js +1 -1
  55. package/dist/components/ImageWithFallback.d.ts +1 -1
  56. package/dist/components/ImageWithFallback.js +1 -1
  57. package/dist/components/Input.d.ts +1 -1
  58. package/dist/components/Input.js +1 -1
  59. package/dist/components/Link.d.ts +1 -1
  60. package/dist/components/Link.js +1 -1
  61. package/dist/components/LoadingPanel.d.ts +1 -1
  62. package/dist/components/LoadingPanel.js +1 -1
  63. package/dist/components/MenuOverlay/Menu.d.ts +1 -1
  64. package/dist/components/MenuOverlay/Menu.js +1 -1
  65. package/dist/components/MenuOverlay/index.d.ts +1 -1
  66. package/dist/components/MenuOverlay/index.js +1 -1
  67. package/dist/components/Overlay/index.d.ts +4 -1
  68. package/dist/components/Overlay/index.d.ts.map +1 -1
  69. package/dist/components/Overlay/index.js +4 -1
  70. package/dist/components/Overlay/index.js.map +1 -1
  71. package/dist/components/Pagination.d.ts +1 -1
  72. package/dist/components/Pagination.js +1 -1
  73. package/dist/components/ProgressBar.d.ts +1 -1
  74. package/dist/components/ProgressBar.js +1 -1
  75. package/dist/components/ProgressCircular.d.ts +1 -1
  76. package/dist/components/ProgressCircular.js +1 -1
  77. package/dist/components/RadioGroup.d.ts +1 -1
  78. package/dist/components/RadioGroup.d.ts.map +1 -1
  79. package/dist/components/RadioGroup.js +2 -2
  80. package/dist/components/RadioGroup.js.map +1 -1
  81. package/dist/components/Rating.d.ts +1 -1
  82. package/dist/components/Rating.js +1 -1
  83. package/dist/components/Select/MultiSelect.d.ts +1 -1
  84. package/dist/components/Select/MultiSelect.js +1 -1
  85. package/dist/components/Select/RichSelect.d.ts +1 -1
  86. package/dist/components/Select/RichSelect.js +1 -1
  87. package/dist/components/Select/SimpleSelect.d.ts +1 -1
  88. package/dist/components/Select/SimpleSelect.js +1 -1
  89. package/dist/components/Select/index.d.ts +1 -1
  90. package/dist/components/Select/index.js +1 -1
  91. package/dist/components/SelectBox.d.ts +1 -1
  92. package/dist/components/SelectBox.js +1 -1
  93. package/dist/components/Skeleton.d.ts +1 -1
  94. package/dist/components/Skeleton.js +1 -1
  95. package/dist/components/Slider.d.ts +1 -1
  96. package/dist/components/Slider.js +1 -1
  97. package/dist/components/SmartTable.d.ts +1 -1
  98. package/dist/components/SmartTable.js +1 -1
  99. package/dist/components/Stepper.d.ts +1 -1
  100. package/dist/components/Stepper.js +1 -1
  101. package/dist/components/Table.d.ts +3 -3
  102. package/dist/components/Table.js +1 -1
  103. package/dist/components/Tabs/index.d.ts +1 -1
  104. package/dist/components/Tabs/index.js +1 -1
  105. package/dist/components/Textarea.d.ts +1 -1
  106. package/dist/components/Textarea.js +1 -1
  107. package/dist/components/Tooltip.d.ts +1 -1
  108. package/dist/components/Tooltip.js +1 -1
  109. package/dist/context/CitricProvider.d.ts +1 -1
  110. package/dist/context/CitricProvider.js +1 -1
  111. package/dist/overlay.js +1 -1
  112. package/dist/theme.css +415 -415
  113. package/package.json +1 -1
  114. package/scripts/build-css.ts +49 -49
  115. package/src/components/Accordion.tsx +130 -130
  116. package/src/components/Alert.tsx +24 -24
  117. package/src/components/AsyncContent.tsx +70 -70
  118. package/src/components/Avatar.tsx +45 -45
  119. package/src/components/AvatarGroup.tsx +49 -49
  120. package/src/components/Badge.tsx +47 -47
  121. package/src/components/Blockquote.tsx +18 -18
  122. package/src/components/Breadcrumb.tsx +33 -33
  123. package/src/components/Button.tsx +105 -105
  124. package/src/components/ButtonLink.tsx +45 -45
  125. package/src/components/Card.tsx +68 -68
  126. package/src/components/Checkbox.tsx +51 -51
  127. package/src/components/CheckboxGroup.tsx +153 -152
  128. package/src/components/Circle.tsx +43 -43
  129. package/src/components/CitricComponent.ts +47 -47
  130. package/src/components/Divider.tsx +24 -24
  131. package/src/components/ErrorBoundary.tsx +75 -75
  132. package/src/components/ErrorMessage.tsx +11 -11
  133. package/src/components/FallbackBoundary.tsx +40 -40
  134. package/src/components/Favorite.tsx +57 -57
  135. package/src/components/FieldGroup.tsx +46 -46
  136. package/src/components/Form.tsx +36 -36
  137. package/src/components/FormGroup.tsx +57 -57
  138. package/src/components/Icon.tsx +35 -35
  139. package/src/components/IconBox.tsx +134 -134
  140. package/src/components/ImageBox.tsx +125 -125
  141. package/src/components/ImageWithFallback.tsx +65 -65
  142. package/src/components/Input.tsx +49 -49
  143. package/src/components/Link.tsx +55 -55
  144. package/src/components/LoadingPanel.tsx +8 -8
  145. package/src/components/MenuOverlay/Menu.tsx +158 -158
  146. package/src/components/MenuOverlay/context.ts +20 -20
  147. package/src/components/MenuOverlay/index.tsx +55 -55
  148. package/src/components/MenuOverlay/keyboard.ts +60 -60
  149. package/src/components/MenuOverlay/types.ts +171 -171
  150. package/src/components/Overlay/context.ts +10 -10
  151. package/src/components/Overlay/index.tsx +167 -164
  152. package/src/components/Overlay/types.ts +70 -70
  153. package/src/components/Pagination.tsx +133 -133
  154. package/src/components/ProgressBar.tsx +45 -45
  155. package/src/components/ProgressCircular.tsx +45 -45
  156. package/src/components/RadioGroup.tsx +147 -146
  157. package/src/components/Rating.tsx +98 -98
  158. package/src/components/Select/MultiSelect.tsx +217 -217
  159. package/src/components/Select/RichSelect.tsx +128 -128
  160. package/src/components/Select/SimpleSelect.tsx +73 -73
  161. package/src/components/Select/hooks.ts +133 -133
  162. package/src/components/Select/index.tsx +35 -35
  163. package/src/components/Select/types.ts +134 -134
  164. package/src/components/SelectBox.tsx +167 -167
  165. package/src/components/Skeleton.tsx +53 -53
  166. package/src/components/Slider.tsx +89 -89
  167. package/src/components/SmartTable.tsx +227 -227
  168. package/src/components/Stepper.tsx +163 -163
  169. package/src/components/Table.tsx +234 -234
  170. package/src/components/Tabs/TabController.ts +54 -54
  171. package/src/components/Tabs/index.tsx +87 -87
  172. package/src/components/Tabs/types.ts +54 -54
  173. package/src/components/Tabs/utils.ts +6 -6
  174. package/src/components/Text.ts +111 -111
  175. package/src/components/Textarea.tsx +27 -27
  176. package/src/components/Tooltip.tsx +72 -72
  177. package/src/components/layout.tsx +101 -101
  178. package/src/context/CitricContext.tsx +4 -4
  179. package/src/context/CitricProvider.tsx +14 -14
  180. package/src/context/hooks.ts +6 -6
  181. package/src/index.ts +58 -58
  182. package/src/overlay.ts +341 -341
  183. package/src/types.ts +216 -216
  184. package/src/utils/ValueController.ts +28 -28
  185. package/src/utils/acessibility.ts +92 -92
  186. package/src/utils/checkbox.ts +121 -121
  187. package/src/utils/css.ts +119 -119
  188. package/src/utils/options.ts +9 -9
  189. package/src/utils/radio.ts +93 -93
  190. package/src/utils/react.ts +6 -6
  191. package/tsconfig.json +10 -10
package/src/overlay.ts CHANGED
@@ -1,341 +1,341 @@
1
- import { createRoot } from 'react-dom/client'
2
- import { HTMLTag, WithDataAttributes } from './types'
3
- import { styleObjectToCssString } from './utils/css'
4
-
5
- export type RelativePosition = 'top' | 'bottom' | 'left' | 'right'
6
-
7
- export interface OverlayOptions<T extends keyof HTMLTag> {
8
- /**
9
- * The tag to render when creating the overlay element.
10
- *
11
- * @default 'div'
12
- */
13
- tag?: T,
14
- /**
15
- * The content of the overlay element.
16
- *
17
- * When this is a string, a number or a boolean, the content will be appended to the overlay element. Otherwise, a new React concurrent
18
- * instance will be created to render the React tree node inside the overlay element.
19
- */
20
- content: React.ReactNode,
21
- /**
22
- * Which element will be used as a reference to position the overlay element.
23
- * This can be either an HTMLElement or an event. If this is a event, the element in `event.target` will be used unless `reference` is
24
- * set to `'mouse'`, in this case, the reference will be the current cursor position.
25
- */
26
- target: Event | HTMLElement,
27
- /**
28
- * Takes effect when `target` is a MouseEvent. If this is set to `'mouse'`, then the position to be used as a reference to place the
29
- * overlay will be the current cursor position.
30
- *
31
- * @default 'element'
32
- */
33
- reference?: 'element' | 'mouse',
34
- /**
35
- * Where, relative to `target`, should we place the overlay?
36
- *
37
- * @default 'top'
38
- */
39
- position?: RelativePosition,
40
- /**
41
- * Affects the positioning of the overlay relative to `target`. While `position` defines the overlay position in the main axis,
42
- * `alignment` defines the position in the cross axis.
43
- *
44
- * @default 'center'
45
- */
46
- alignment?: 'start' | 'center' | 'end',
47
- /**
48
- * TODO: implement this (currently, only 'fade' works).
49
- *
50
- * - fade: the overlay fades in to appear and fades out to disappear.
51
- * - accordion: the element grows to appear and shrinks to disappear. The direction of the growth depends on `position`.
52
- * - bubble: grows from scale(0) to scale(1) from its center to appear. Shrinks from scale(1) to scale(0) to disappear.
53
- * - none: no animation is used.
54
- *
55
- * @default 'fade'
56
- */
57
- animation?: 'fade' | 'accordion' | 'bubble' | 'none',
58
- /**
59
- * The attributes for the HTMLElement that will be created for the overlay.
60
- */
61
- attributes?: JSX.IntrinsicElements[T] & WithDataAttributes & { inert?: boolean },
62
- }
63
-
64
- interface Position {
65
- top: number,
66
- left: number,
67
- }
68
-
69
- interface PositionWithRelativeData extends Position {
70
- relativeTo: RelativePosition,
71
- }
72
-
73
- type CalculatePosOptions = Pick<Required<OverlayOptions<any>>, 'position' | 'target' | 'reference' | 'alignment'>
74
- & { overlay: HTMLElement }
75
-
76
- const animationDurationMS = 300
77
-
78
- function hasMargins(element: HTMLElement) {
79
- const s = element.style
80
- return s.margin || s.marginTop || s.marginBottom || s.marginLeft || s.marginRight
81
- }
82
-
83
- function calculateAlignment(alignment: CalculatePosOptions['alignment'], offset: number, size: number, scroll: number) {
84
- switch (alignment) {
85
- case 'center': return offset + size / 2 + scroll
86
- case 'end': return offset + size + scroll
87
- case 'start': return offset + scroll
88
- }
89
- }
90
-
91
- function calculateAlignmentOffset(alignment: CalculatePosOptions['alignment'], overlaySize: number) {
92
- switch (alignment) {
93
- case 'center': return overlaySize / 2
94
- case 'end': return overlaySize
95
- case 'start': return 0
96
- }
97
- }
98
-
99
- function calculatePosition({
100
- overlay,
101
- reference,
102
- target,
103
- position: relativePosition,
104
- alignment,
105
- }: CalculatePosOptions): Position & { overlayWidth: number, overlayHeight: number } {
106
- const overlayDimensions = overlay.getBoundingClientRect()
107
- if (hasMargins(overlay)) {
108
- const style = overlay.computedStyleMap()
109
- const mt = parseInt(style.get('margin-top')?.toString() ?? '0')
110
- const mb = parseInt(style.get('margin-bottom')?.toString() ?? '0')
111
- const ml = parseInt(style.get('margin-left')?.toString() ?? '0')
112
- const mr = parseInt(style.get('margin-right')?.toString() ?? '0')
113
- if (mt) overlayDimensions.height += mt
114
- if (mb) overlayDimensions.height += mb
115
- if (ml) overlayDimensions.width += ml
116
- if (mr) overlayDimensions.width += mr
117
- }
118
- const referencePosition = { top: 0, left: 0, overlayWidth: overlayDimensions.width, overlayHeight: overlayDimensions.height }
119
- if (reference === 'mouse' && target instanceof MouseEvent) {
120
- referencePosition.top = target.clientY
121
- referencePosition.left = target.clientX
122
- } else {
123
- const element = target instanceof Event ? target.target : target
124
- if (!(element instanceof HTMLElement)) return referencePosition
125
- const elementDimensions = element.getBoundingClientRect()
126
- switch (relativePosition) {
127
- case 'top':
128
- referencePosition.top = elementDimensions.top + window.scrollY
129
- referencePosition.left = calculateAlignment(alignment, elementDimensions.left, elementDimensions.width, window.scrollX)
130
- break
131
- case 'bottom':
132
- referencePosition.top = elementDimensions.bottom + window.scrollY
133
- referencePosition.left = calculateAlignment(alignment, elementDimensions.left, elementDimensions.width, window.scrollX)
134
- break
135
- case 'left':
136
- referencePosition.top = calculateAlignment(alignment, elementDimensions.top, elementDimensions.height, window.scrollY)
137
- referencePosition.left = elementDimensions.left + window.scrollX
138
- break
139
- case 'right':
140
- referencePosition.top = calculateAlignment(alignment, elementDimensions.top, elementDimensions.height, window.scrollY)
141
- referencePosition.left = elementDimensions.right + window.scrollX
142
- }
143
- }
144
- const position = { ...referencePosition }
145
- switch (relativePosition) {
146
- case 'top':
147
- position.top -= overlayDimensions.height
148
- position.left -= calculateAlignmentOffset(alignment, overlayDimensions.width)
149
- break
150
- case 'bottom':
151
- position.left -= calculateAlignmentOffset(alignment, overlayDimensions.width)
152
- break
153
- case 'left':
154
- position.top -= calculateAlignmentOffset(alignment, overlayDimensions.height)
155
- position.left -= overlayDimensions.width
156
- break
157
- case 'right':
158
- position.top -= calculateAlignmentOffset(alignment, overlayDimensions.height)
159
- }
160
- return position
161
- }
162
-
163
- function getSafeOverlayPosition(
164
- options: Omit<CalculatePosOptions, 'position'>, positionPriority: RelativePosition[], fallback?: PositionWithRelativeData,
165
- ): PositionWithRelativeData {
166
- if (!positionPriority.length) return fallback ?? { top: 0, left: 0, relativeTo: 'top' }
167
- const [relativePosition, ...remainingRelativePositions] = positionPriority
168
- const position = { ...calculatePosition({ ...options, position: relativePosition }), relativeTo: relativePosition }
169
- switch (relativePosition) {
170
- case 'top':
171
- if (position.left < 0) position.left = 0
172
- if (position.left + position.overlayWidth > document.body.clientWidth) {
173
- position.left = document.body.clientWidth - position.overlayWidth
174
- }
175
- if (position.top < 0) return getSafeOverlayPosition(options, remainingRelativePositions, fallback ?? position)
176
- break
177
- case 'bottom':
178
- if (position.left < 0) position.left = 0
179
- if (position.left + position.overlayWidth > document.body.clientWidth) {
180
- position.left = document.body.clientWidth - position.overlayWidth
181
- }
182
- if (position.top + position.overlayHeight > document.body.clientHeight) {
183
- return getSafeOverlayPosition(options, remainingRelativePositions, fallback ?? position)
184
- }
185
- break
186
- case 'left':
187
- if (position.top < 0) position.top = 0
188
- if (position.top + position.overlayHeight > document.body.clientHeight) {
189
- position.top = document.body.clientHeight - position.overlayHeight
190
- }
191
- if (position.left < 0) return getSafeOverlayPosition(options, remainingRelativePositions, fallback ?? position)
192
- break
193
- case 'right':
194
- if (position.top < 0) position.top = 0
195
- if (position.top + position.overlayHeight > document.body.clientHeight) {
196
- position.top = document.body.clientHeight - position.overlayHeight
197
- }
198
- if (position.left + position.overlayWidth > document.body.clientWidth) {
199
- return getSafeOverlayPosition(options, remainingRelativePositions, fallback ?? position)
200
- }
201
- }
202
- return position
203
- }
204
-
205
- function invert(position: RelativePosition): RelativePosition {
206
- switch (position) {
207
- case 'bottom': return 'top'
208
- case 'top': return 'bottom'
209
- case 'left': return 'right'
210
- case 'right': return 'left'
211
- }
212
- }
213
-
214
- function oppositeAxis(position: RelativePosition): [RelativePosition, RelativePosition] {
215
- return (position === 'top' || position === 'bottom') ? ['left', 'right'] : ['top', 'bottom']
216
- }
217
-
218
- function reactAttributeToHTML(attribute: string) {
219
- return attribute === 'className' ? 'class' : attribute
220
- }
221
-
222
- function setElementAttributes(element: HTMLElement, attributes: Record<string, any> | undefined, ignore: string[] = []) {
223
- for (const attr in attributes) {
224
- if (attributes[attr] !== undefined && !ignore.includes(attr)) element.setAttribute(reactAttributeToHTML(attr), attributes[attr])
225
- }
226
- }
227
-
228
- function getClosestScrollable(element: HTMLElement, limit: HTMLElement = document.body): HTMLElement | null | undefined {
229
- if (element === limit) return
230
- return element.clientHeight === element.scrollHeight ? getClosestScrollable(element.parentElement!, limit) : element
231
- }
232
-
233
- function isElementVisible(element: HTMLElement, scrollable: HTMLElement) {
234
- const elementRect = element.getBoundingClientRect()
235
- const scrollableRect = scrollable.getBoundingClientRect()
236
- const diffX = elementRect.left - scrollableRect.left + scrollable.scrollLeft
237
- const isVisibleX = diffX + elementRect.width <= scrollable.scrollLeft + scrollableRect.width &&
238
- diffX >= scrollable.scrollLeft
239
- const diffY = elementRect.top - scrollableRect.top + scrollable.scrollTop
240
- const isVisibleY = diffY + elementRect.height <= scrollable.scrollTop + scrollableRect.height &&
241
- diffY >= scrollable.scrollTop
242
- return isVisibleX && isVisibleY
243
- }
244
-
245
- /**
246
- * The tooltip may be inside a scrollable element. If this is the case, we must update its position whenever the container is scrolled.
247
- * If the container is scrolled enough to hide the element that triggered the overlay, we hide the overlay.
248
- */
249
- function attachScrollEffects(target: OverlayOptions<any>['target'], overlay: HTMLElement, hide: () => void) {
250
- const element = target instanceof Event ? target.target as HTMLElement : target
251
- const closestScrollableFromTarget = getClosestScrollable(element, overlay.parentNode as HTMLElement)
252
- if (closestScrollableFromTarget) {
253
- let lastScrollX = closestScrollableFromTarget.scrollLeft
254
- let lastScrollY = closestScrollableFromTarget.scrollTop
255
- const updatePosition = () => {
256
- if (!isElementVisible(element, closestScrollableFromTarget)) return hide()
257
- const diffX = closestScrollableFromTarget.scrollLeft - lastScrollX
258
- const diffY = closestScrollableFromTarget.scrollTop - lastScrollY
259
- overlay.style.left = `${parseInt(overlay.style.left) - diffX}px`
260
- overlay.style.top = `${parseInt(overlay.style.top) - diffY}px`
261
- lastScrollX = closestScrollableFromTarget.scrollLeft
262
- lastScrollY = closestScrollableFromTarget.scrollTop
263
- }
264
- closestScrollableFromTarget.addEventListener('scroll', updatePosition)
265
- return () => closestScrollableFromTarget.removeEventListener('scroll', updatePosition)
266
- }
267
- }
268
-
269
- /**
270
- * Appends a new HTML Element to the tag "body". This element is absolutely positioned and its position is calculated according to the
271
- * options passed as parameter.
272
- *
273
- * This function returns both the newly created HTML Element and a function to remove it.
274
- * @param options {@link OverlayOptions}.
275
- * @returns an object with two keys: "overlay" (the HTML Element created) and "hide" (a function to remove the element from the document).
276
- */
277
- export function showOverlay<T extends keyof HTMLTag = 'div'>(
278
- { tag, content, target, reference = 'element', position = 'top', alignment = 'center', attributes }: OverlayOptions<T>,
279
- ) {
280
- let removeScrollEffects: (() => void) | undefined
281
- const overlay = document.createElement(tag || 'div')
282
- overlay.style = `z-index: 9999; pointer-events: none; position: absolute; opacity: 0; transition: opacity ${animationDurationMS / 1000}s; ${styleObjectToCssString(attributes?.style)}`
283
- overlay.inert = true
284
- setElementAttributes(overlay, attributes, ['style', 'inert'])
285
- document.body.append(overlay)
286
- let unmount: (() => void) | undefined
287
- if (['string', 'number', 'boolean'].includes(typeof content)) {
288
- overlay.append(`${content}`)
289
- unmount = () => document.body.removeChild(overlay)
290
- } else {
291
- const root = createRoot(overlay)
292
- root.render(content)
293
- unmount = () => {
294
- root.unmount()
295
- document.body.removeChild(overlay)
296
- }
297
- }
298
-
299
- const hide = () => new Promise<void>((resolve) => {
300
- overlay.style.opacity = '0'
301
- overlay.style.pointerEvents = 'none'
302
- overlay.inert = true
303
- setTimeout(() => {
304
- try {
305
- removeScrollEffects?.()
306
- unmount()
307
- } catch { /* empty */ }
308
- resolve()
309
- }, animationDurationMS)
310
- })
311
-
312
- setTimeout(() => {
313
- const overlayPos = getSafeOverlayPosition(
314
- { alignment, overlay, reference, target },
315
- [position, invert(position), ...oppositeAxis(position)],
316
- )
317
- overlay.style = `z-index: 9999; position: absolute; opacity: 1; transition: opacity ${animationDurationMS / 1000}s; top: ${overlayPos.top}px; left: ${overlayPos.left}px; ${styleObjectToCssString(attributes?.style)}`
318
- if (attributes && 'inert' in attributes && attributes.inert) overlay.style.pointerEvents = 'none'
319
- else overlay.inert = false
320
- overlay.classList.add(overlayPos.relativeTo, `align-${alignment}`)
321
- removeScrollEffects = attachScrollEffects(target, overlay, hide)
322
- }, 0)
323
-
324
- return {
325
- /**
326
- * The overlay element created.
327
- */
328
- overlay,
329
- /**
330
- * Removes the overlay element.
331
- * @returns a promise that completes when the element is fully removed (after any animation).
332
- */
333
- hide,
334
- /**
335
- * Returns a promise that resolves as soon as the overlay finishes the animation to show up.
336
- */
337
- ready: new Promise<void>((resolve) => {
338
- setTimeout(resolve, animationDurationMS)
339
- }),
340
- }
341
- }
1
+ import { createRoot } from 'react-dom/client'
2
+ import { HTMLTag, WithDataAttributes } from './types'
3
+ import { styleObjectToCssString } from './utils/css'
4
+
5
+ export type RelativePosition = 'top' | 'bottom' | 'left' | 'right'
6
+
7
+ export interface OverlayOptions<T extends keyof HTMLTag> {
8
+ /**
9
+ * The tag to render when creating the overlay element.
10
+ *
11
+ * @default 'div'
12
+ */
13
+ tag?: T,
14
+ /**
15
+ * The content of the overlay element.
16
+ *
17
+ * When this is a string, a number or a boolean, the content will be appended to the overlay element. Otherwise, a new React concurrent
18
+ * instance will be created to render the React tree node inside the overlay element.
19
+ */
20
+ content: React.ReactNode,
21
+ /**
22
+ * Which element will be used as a reference to position the overlay element.
23
+ * This can be either an HTMLElement or an event. If this is a event, the element in `event.target` will be used unless `reference` is
24
+ * set to `'mouse'`, in this case, the reference will be the current cursor position.
25
+ */
26
+ target: Event | HTMLElement,
27
+ /**
28
+ * Takes effect when `target` is a MouseEvent. If this is set to `'mouse'`, then the position to be used as a reference to place the
29
+ * overlay will be the current cursor position.
30
+ *
31
+ * @default 'element'
32
+ */
33
+ reference?: 'element' | 'mouse',
34
+ /**
35
+ * Where, relative to `target`, should we place the overlay?
36
+ *
37
+ * @default 'top'
38
+ */
39
+ position?: RelativePosition,
40
+ /**
41
+ * Affects the positioning of the overlay relative to `target`. While `position` defines the overlay position in the main axis,
42
+ * `alignment` defines the position in the cross axis.
43
+ *
44
+ * @default 'center'
45
+ */
46
+ alignment?: 'start' | 'center' | 'end',
47
+ /**
48
+ * TODO: implement this (currently, only 'fade' works).
49
+ *
50
+ * - fade: the overlay fades in to appear and fades out to disappear.
51
+ * - accordion: the element grows to appear and shrinks to disappear. The direction of the growth depends on `position`.
52
+ * - bubble: grows from scale(0) to scale(1) from its center to appear. Shrinks from scale(1) to scale(0) to disappear.
53
+ * - none: no animation is used.
54
+ *
55
+ * @default 'fade'
56
+ */
57
+ animation?: 'fade' | 'accordion' | 'bubble' | 'none',
58
+ /**
59
+ * The attributes for the HTMLElement that will be created for the overlay.
60
+ */
61
+ attributes?: JSX.IntrinsicElements[T] & WithDataAttributes & { inert?: boolean },
62
+ }
63
+
64
+ interface Position {
65
+ top: number,
66
+ left: number,
67
+ }
68
+
69
+ interface PositionWithRelativeData extends Position {
70
+ relativeTo: RelativePosition,
71
+ }
72
+
73
+ type CalculatePosOptions = Pick<Required<OverlayOptions<any>>, 'position' | 'target' | 'reference' | 'alignment'>
74
+ & { overlay: HTMLElement }
75
+
76
+ const animationDurationMS = 300
77
+
78
+ function hasMargins(element: HTMLElement) {
79
+ const s = element.style
80
+ return s.margin || s.marginTop || s.marginBottom || s.marginLeft || s.marginRight
81
+ }
82
+
83
+ function calculateAlignment(alignment: CalculatePosOptions['alignment'], offset: number, size: number, scroll: number) {
84
+ switch (alignment) {
85
+ case 'center': return offset + size / 2 + scroll
86
+ case 'end': return offset + size + scroll
87
+ case 'start': return offset + scroll
88
+ }
89
+ }
90
+
91
+ function calculateAlignmentOffset(alignment: CalculatePosOptions['alignment'], overlaySize: number) {
92
+ switch (alignment) {
93
+ case 'center': return overlaySize / 2
94
+ case 'end': return overlaySize
95
+ case 'start': return 0
96
+ }
97
+ }
98
+
99
+ function calculatePosition({
100
+ overlay,
101
+ reference,
102
+ target,
103
+ position: relativePosition,
104
+ alignment,
105
+ }: CalculatePosOptions): Position & { overlayWidth: number, overlayHeight: number } {
106
+ const overlayDimensions = overlay.getBoundingClientRect()
107
+ if (hasMargins(overlay)) {
108
+ const style = overlay.computedStyleMap()
109
+ const mt = parseInt(style.get('margin-top')?.toString() ?? '0')
110
+ const mb = parseInt(style.get('margin-bottom')?.toString() ?? '0')
111
+ const ml = parseInt(style.get('margin-left')?.toString() ?? '0')
112
+ const mr = parseInt(style.get('margin-right')?.toString() ?? '0')
113
+ if (mt) overlayDimensions.height += mt
114
+ if (mb) overlayDimensions.height += mb
115
+ if (ml) overlayDimensions.width += ml
116
+ if (mr) overlayDimensions.width += mr
117
+ }
118
+ const referencePosition = { top: 0, left: 0, overlayWidth: overlayDimensions.width, overlayHeight: overlayDimensions.height }
119
+ if (reference === 'mouse' && target instanceof MouseEvent) {
120
+ referencePosition.top = target.clientY
121
+ referencePosition.left = target.clientX
122
+ } else {
123
+ const element = target instanceof Event ? target.target : target
124
+ if (!(element instanceof HTMLElement)) return referencePosition
125
+ const elementDimensions = element.getBoundingClientRect()
126
+ switch (relativePosition) {
127
+ case 'top':
128
+ referencePosition.top = elementDimensions.top + window.scrollY
129
+ referencePosition.left = calculateAlignment(alignment, elementDimensions.left, elementDimensions.width, window.scrollX)
130
+ break
131
+ case 'bottom':
132
+ referencePosition.top = elementDimensions.bottom + window.scrollY
133
+ referencePosition.left = calculateAlignment(alignment, elementDimensions.left, elementDimensions.width, window.scrollX)
134
+ break
135
+ case 'left':
136
+ referencePosition.top = calculateAlignment(alignment, elementDimensions.top, elementDimensions.height, window.scrollY)
137
+ referencePosition.left = elementDimensions.left + window.scrollX
138
+ break
139
+ case 'right':
140
+ referencePosition.top = calculateAlignment(alignment, elementDimensions.top, elementDimensions.height, window.scrollY)
141
+ referencePosition.left = elementDimensions.right + window.scrollX
142
+ }
143
+ }
144
+ const position = { ...referencePosition }
145
+ switch (relativePosition) {
146
+ case 'top':
147
+ position.top -= overlayDimensions.height
148
+ position.left -= calculateAlignmentOffset(alignment, overlayDimensions.width)
149
+ break
150
+ case 'bottom':
151
+ position.left -= calculateAlignmentOffset(alignment, overlayDimensions.width)
152
+ break
153
+ case 'left':
154
+ position.top -= calculateAlignmentOffset(alignment, overlayDimensions.height)
155
+ position.left -= overlayDimensions.width
156
+ break
157
+ case 'right':
158
+ position.top -= calculateAlignmentOffset(alignment, overlayDimensions.height)
159
+ }
160
+ return position
161
+ }
162
+
163
+ function getSafeOverlayPosition(
164
+ options: Omit<CalculatePosOptions, 'position'>, positionPriority: RelativePosition[], fallback?: PositionWithRelativeData,
165
+ ): PositionWithRelativeData {
166
+ if (!positionPriority.length) return fallback ?? { top: 0, left: 0, relativeTo: 'top' }
167
+ const [relativePosition, ...remainingRelativePositions] = positionPriority
168
+ const position = { ...calculatePosition({ ...options, position: relativePosition }), relativeTo: relativePosition }
169
+ switch (relativePosition) {
170
+ case 'top':
171
+ if (position.left < 0) position.left = 0
172
+ if (position.left + position.overlayWidth > document.body.clientWidth) {
173
+ position.left = document.body.clientWidth - position.overlayWidth
174
+ }
175
+ if (position.top < 0) return getSafeOverlayPosition(options, remainingRelativePositions, fallback ?? position)
176
+ break
177
+ case 'bottom':
178
+ if (position.left < 0) position.left = 0
179
+ if (position.left + position.overlayWidth > document.body.clientWidth) {
180
+ position.left = document.body.clientWidth - position.overlayWidth
181
+ }
182
+ if (position.top + position.overlayHeight > document.body.clientHeight) {
183
+ return getSafeOverlayPosition(options, remainingRelativePositions, fallback ?? position)
184
+ }
185
+ break
186
+ case 'left':
187
+ if (position.top < 0) position.top = 0
188
+ if (position.top + position.overlayHeight > document.body.clientHeight) {
189
+ position.top = document.body.clientHeight - position.overlayHeight
190
+ }
191
+ if (position.left < 0) return getSafeOverlayPosition(options, remainingRelativePositions, fallback ?? position)
192
+ break
193
+ case 'right':
194
+ if (position.top < 0) position.top = 0
195
+ if (position.top + position.overlayHeight > document.body.clientHeight) {
196
+ position.top = document.body.clientHeight - position.overlayHeight
197
+ }
198
+ if (position.left + position.overlayWidth > document.body.clientWidth) {
199
+ return getSafeOverlayPosition(options, remainingRelativePositions, fallback ?? position)
200
+ }
201
+ }
202
+ return position
203
+ }
204
+
205
+ function invert(position: RelativePosition): RelativePosition {
206
+ switch (position) {
207
+ case 'bottom': return 'top'
208
+ case 'top': return 'bottom'
209
+ case 'left': return 'right'
210
+ case 'right': return 'left'
211
+ }
212
+ }
213
+
214
+ function oppositeAxis(position: RelativePosition): [RelativePosition, RelativePosition] {
215
+ return (position === 'top' || position === 'bottom') ? ['left', 'right'] : ['top', 'bottom']
216
+ }
217
+
218
+ function reactAttributeToHTML(attribute: string) {
219
+ return attribute === 'className' ? 'class' : attribute
220
+ }
221
+
222
+ function setElementAttributes(element: HTMLElement, attributes: Record<string, any> | undefined, ignore: string[] = []) {
223
+ for (const attr in attributes) {
224
+ if (attributes[attr] !== undefined && !ignore.includes(attr)) element.setAttribute(reactAttributeToHTML(attr), attributes[attr])
225
+ }
226
+ }
227
+
228
+ function getClosestScrollable(element: HTMLElement, limit: HTMLElement = document.body): HTMLElement | null | undefined {
229
+ if (element === limit) return
230
+ return element.clientHeight === element.scrollHeight ? getClosestScrollable(element.parentElement!, limit) : element
231
+ }
232
+
233
+ function isElementVisible(element: HTMLElement, scrollable: HTMLElement) {
234
+ const elementRect = element.getBoundingClientRect()
235
+ const scrollableRect = scrollable.getBoundingClientRect()
236
+ const diffX = elementRect.left - scrollableRect.left + scrollable.scrollLeft
237
+ const isVisibleX = diffX + elementRect.width <= scrollable.scrollLeft + scrollableRect.width &&
238
+ diffX >= scrollable.scrollLeft
239
+ const diffY = elementRect.top - scrollableRect.top + scrollable.scrollTop
240
+ const isVisibleY = diffY + elementRect.height <= scrollable.scrollTop + scrollableRect.height &&
241
+ diffY >= scrollable.scrollTop
242
+ return isVisibleX && isVisibleY
243
+ }
244
+
245
+ /**
246
+ * The tooltip may be inside a scrollable element. If this is the case, we must update its position whenever the container is scrolled.
247
+ * If the container is scrolled enough to hide the element that triggered the overlay, we hide the overlay.
248
+ */
249
+ function attachScrollEffects(target: OverlayOptions<any>['target'], overlay: HTMLElement, hide: () => void) {
250
+ const element = target instanceof Event ? target.target as HTMLElement : target
251
+ const closestScrollableFromTarget = getClosestScrollable(element, overlay.parentNode as HTMLElement)
252
+ if (closestScrollableFromTarget) {
253
+ let lastScrollX = closestScrollableFromTarget.scrollLeft
254
+ let lastScrollY = closestScrollableFromTarget.scrollTop
255
+ const updatePosition = () => {
256
+ if (!isElementVisible(element, closestScrollableFromTarget)) return hide()
257
+ const diffX = closestScrollableFromTarget.scrollLeft - lastScrollX
258
+ const diffY = closestScrollableFromTarget.scrollTop - lastScrollY
259
+ overlay.style.left = `${parseInt(overlay.style.left) - diffX}px`
260
+ overlay.style.top = `${parseInt(overlay.style.top) - diffY}px`
261
+ lastScrollX = closestScrollableFromTarget.scrollLeft
262
+ lastScrollY = closestScrollableFromTarget.scrollTop
263
+ }
264
+ closestScrollableFromTarget.addEventListener('scroll', updatePosition)
265
+ return () => closestScrollableFromTarget.removeEventListener('scroll', updatePosition)
266
+ }
267
+ }
268
+
269
+ /**
270
+ * Appends a new HTML Element to the tag "body". This element is absolutely positioned and its position is calculated according to the
271
+ * options passed as parameter.
272
+ *
273
+ * This function returns both the newly created HTML Element and a function to remove it.
274
+ * @param options {@link OverlayOptions}.
275
+ * @returns an object with two keys: "overlay" (the HTML Element created) and "hide" (a function to remove the element from the document).
276
+ */
277
+ export function showOverlay<T extends keyof HTMLTag = 'div'>(
278
+ { tag, content, target, reference = 'element', position = 'top', alignment = 'center', attributes }: OverlayOptions<T>,
279
+ ) {
280
+ let removeScrollEffects: (() => void) | undefined
281
+ const overlay = document.createElement(tag || 'div')
282
+ overlay.style = `z-index: 9999; pointer-events: none; position: absolute; opacity: 0; transition: opacity ${animationDurationMS / 1000}s; ${styleObjectToCssString(attributes?.style)}`
283
+ overlay.inert = true
284
+ setElementAttributes(overlay, attributes, ['style', 'inert'])
285
+ document.body.append(overlay)
286
+ let unmount: (() => void) | undefined
287
+ if (['string', 'number', 'boolean'].includes(typeof content)) {
288
+ overlay.append(`${content}`)
289
+ unmount = () => document.body.removeChild(overlay)
290
+ } else {
291
+ const root = createRoot(overlay)
292
+ root.render(content)
293
+ unmount = () => {
294
+ root.unmount()
295
+ document.body.removeChild(overlay)
296
+ }
297
+ }
298
+
299
+ const hide = () => new Promise<void>((resolve) => {
300
+ overlay.style.opacity = '0'
301
+ overlay.style.pointerEvents = 'none'
302
+ overlay.inert = true
303
+ setTimeout(() => {
304
+ try {
305
+ removeScrollEffects?.()
306
+ unmount()
307
+ } catch { /* empty */ }
308
+ resolve()
309
+ }, animationDurationMS)
310
+ })
311
+
312
+ setTimeout(() => {
313
+ const overlayPos = getSafeOverlayPosition(
314
+ { alignment, overlay, reference, target },
315
+ [position, invert(position), ...oppositeAxis(position)],
316
+ )
317
+ overlay.style = `z-index: 9999; position: absolute; opacity: 1; transition: opacity ${animationDurationMS / 1000}s; top: ${overlayPos.top}px; left: ${overlayPos.left}px; ${styleObjectToCssString(attributes?.style)}`
318
+ if (attributes && 'inert' in attributes && attributes.inert) overlay.style.pointerEvents = 'none'
319
+ else overlay.inert = false
320
+ overlay.classList.add(overlayPos.relativeTo, `align-${alignment}`)
321
+ removeScrollEffects = attachScrollEffects(target, overlay, hide)
322
+ }, 0)
323
+
324
+ return {
325
+ /**
326
+ * The overlay element created.
327
+ */
328
+ overlay,
329
+ /**
330
+ * Removes the overlay element.
331
+ * @returns a promise that completes when the element is fully removed (after any animation).
332
+ */
333
+ hide,
334
+ /**
335
+ * Returns a promise that resolves as soon as the overlay finishes the animation to show up.
336
+ */
337
+ ready: new Promise<void>((resolve) => {
338
+ setTimeout(resolve, animationDurationMS)
339
+ }),
340
+ }
341
+ }