@stack-spot/citric-react 0.27.2 → 0.29.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 (53) hide show
  1. package/dist/citric.css +39 -10
  2. package/dist/components/AsyncContent.d.ts +1 -0
  3. package/dist/components/AsyncContent.d.ts.map +1 -1
  4. package/dist/components/AsyncContent.js +1 -0
  5. package/dist/components/AsyncContent.js.map +1 -1
  6. package/dist/components/Button.d.ts +5 -5
  7. package/dist/components/Button.d.ts.map +1 -1
  8. package/dist/components/Button.js +3 -3
  9. package/dist/components/Button.js.map +1 -1
  10. package/dist/components/ButtonLink.d.ts +2 -2
  11. package/dist/components/ButtonLink.d.ts.map +1 -1
  12. package/dist/components/ButtonLink.js +3 -3
  13. package/dist/components/ButtonLink.js.map +1 -1
  14. package/dist/components/IconBox.d.ts +6 -6
  15. package/dist/components/IconBox.d.ts.map +1 -1
  16. package/dist/components/IconBox.js +5 -5
  17. package/dist/components/IconBox.js.map +1 -1
  18. package/dist/components/ImageBox.d.ts +8 -6
  19. package/dist/components/ImageBox.d.ts.map +1 -1
  20. package/dist/components/ImageBox.js +7 -5
  21. package/dist/components/ImageBox.js.map +1 -1
  22. package/dist/components/Link.d.ts +5 -5
  23. package/dist/components/Link.d.ts.map +1 -1
  24. package/dist/components/Link.js +3 -3
  25. package/dist/components/Link.js.map +1 -1
  26. package/dist/components/Overlay/index.d.ts +1 -1
  27. package/dist/components/Overlay/index.d.ts.map +1 -1
  28. package/dist/components/Overlay/index.js +5 -4
  29. package/dist/components/Overlay/index.js.map +1 -1
  30. package/dist/components/Select/MultiSelect.d.ts.map +1 -1
  31. package/dist/components/Select/MultiSelect.js +1 -3
  32. package/dist/components/Select/MultiSelect.js.map +1 -1
  33. package/dist/components/Select/RichSelect.d.ts.map +1 -1
  34. package/dist/components/Select/RichSelect.js +1 -3
  35. package/dist/components/Select/RichSelect.js.map +1 -1
  36. package/dist/overlay.d.ts +1 -3
  37. package/dist/overlay.d.ts.map +1 -1
  38. package/dist/overlay.js +82 -24
  39. package/dist/overlay.js.map +1 -1
  40. package/dist/types.d.ts +4 -4
  41. package/dist/types.d.ts.map +1 -1
  42. package/package.json +1 -1
  43. package/src/components/AsyncContent.tsx +1 -0
  44. package/src/components/Button.tsx +6 -6
  45. package/src/components/ButtonLink.tsx +3 -3
  46. package/src/components/IconBox.tsx +8 -8
  47. package/src/components/ImageBox.tsx +10 -8
  48. package/src/components/Link.tsx +6 -6
  49. package/src/components/Overlay/index.tsx +5 -3
  50. package/src/components/Select/MultiSelect.tsx +1 -2
  51. package/src/components/Select/RichSelect.tsx +1 -2
  52. package/src/overlay.ts +87 -25
  53. package/src/types.ts +4 -4
@@ -11,14 +11,14 @@ export interface BaseLinkProps extends WithColor {
11
11
  */
12
12
  appearance?: TextAppearance,
13
13
  /**
14
- * Whether or not a click in this link should generate analytics data.
14
+ * Metadata for the general onClick event, set by the CitricController. Useful for creating analytics data.
15
15
  *
16
- * This only takes effect if there's a CitricController in React's context. The value of `analytics` is passed to the function
16
+ * This only takes effect if there's a CitricController in React's context. The value of `metadata` is passed to the function
17
17
  * `onClickLink` of the controller.
18
18
  *
19
19
  * @default false
20
20
  */
21
- analytics?: boolean,
21
+ metadata?: any,
22
22
  }
23
23
 
24
24
  export type LinkProps = React.JSX.IntrinsicElements['a'] & BaseLinkProps
@@ -27,9 +27,9 @@ export type LinkProps = React.JSX.IntrinsicElements['a'] & BaseLinkProps
27
27
  * Renders an html anchor by default, the actual component to render may be set on a CitricController, through the function `renderLink`.
28
28
  *
29
29
  * Whenever a link is clicked, the function `onClickLink` of the nearest CitricController is called with the event and the value of the
30
- * prop `analytics`.
30
+ * prop `metadata`.
31
31
  */
32
- export const Link = withRef(({ appearance, color, style, className, children, onClick, analytics, ...props }: LinkProps) => {
32
+ export const Link = withRef(({ appearance, color, style, className, children, onClick, metadata, ...props }: LinkProps) => {
33
33
  const citric = useCitricController()
34
34
  const linkProps = {
35
35
  component: 'link',
@@ -37,7 +37,7 @@ export const Link = withRef(({ appearance, color, style, className, children, on
37
37
  className: applyTextAppearance(className, appearance),
38
38
  onClick: (e: React.MouseEvent<HTMLAnchorElement>) => {
39
39
  onClick?.(e)
40
- citric?.onClickLink?.(e, analytics ?? false)
40
+ citric?.onClickLink?.(e, metadata ?? false)
41
41
  },
42
42
  ...props,
43
43
  } as const
@@ -44,6 +44,7 @@ export function Overlay<T extends keyof HTMLTag>({
44
44
  content,
45
45
  position = 'top',
46
46
  triggerOn = 'hover',
47
+ alignment = 'center',
47
48
  attributes,
48
49
  onRenderChild,
49
50
  autoFocusBehavior = 'keyboard',
@@ -53,11 +54,11 @@ export function Overlay<T extends keyof HTMLTag>({
53
54
  const controller = useRef<OverlayController>({ close: () => Promise.resolve() })
54
55
  const wrapper = useRef<HTMLDivElement | null>(null)
55
56
  // props that don't require removing and reattaching the event listeners
56
- const dynamic = useRef({ tag, content, position, attributes })
57
+ const dynamic = useRef({ tag, content, position, alignment, attributes })
57
58
 
58
59
  useEffect(() => {
59
- dynamic.current = { tag, content, position, attributes }
60
- }, [tag, content, position, attributes])
60
+ dynamic.current = { tag, content, position, alignment, attributes }
61
+ }, [tag, content, position, alignment, attributes])
61
62
 
62
63
  useEffect(() => {
63
64
  let visible = false
@@ -88,6 +89,7 @@ export function Overlay<T extends keyof HTMLTag>({
88
89
  : <OverlayProvider value={controller.current}>{dynamic.current.content}</OverlayProvider>,
89
90
  target,
90
91
  position: dynamic.current.position,
92
+ alignment: dynamic.current.alignment,
91
93
  attributes: dynamic.current.attributes,
92
94
  })
93
95
  hideOverlay = hideFn
@@ -134,9 +134,8 @@ export const MultiSelect = withRef(
134
134
  {...props}
135
135
  >
136
136
  <header
137
- onClick={(e) => {
137
+ onClick={() => {
138
138
  if (disabled) return
139
- if (!open) e.stopPropagation()
140
139
  setFocused(true)
141
140
  setOpen(true)
142
141
  }}
@@ -92,9 +92,8 @@ export const RichSelect = withRef(
92
92
  wrap={false}
93
93
  />
94
94
  <header
95
- onClick={(e) => {
95
+ onClick={() => {
96
96
  if (disabled) return
97
- if (!open) e.stopPropagation()
98
97
  setFocused(true)
99
98
  setOpen(true)
100
99
  }}
package/src/overlay.ts CHANGED
@@ -38,8 +38,6 @@ export interface OverlayOptions<T extends keyof HTMLTag> {
38
38
  */
39
39
  position?: RelativePosition,
40
40
  /**
41
- * TODO: implement this.
42
- *
43
41
  * Affects the positioning of the overlay relative to `target`. While `position` defines the overlay position in the main axis,
44
42
  * `alignment` defines the position in the cross axis.
45
43
  *
@@ -72,7 +70,7 @@ interface PositionWithRelativeData extends Position {
72
70
  relativeTo: RelativePosition,
73
71
  }
74
72
 
75
- type CalculatePosOptions = Pick<Required<OverlayOptions<any>>, 'position' | 'target' | 'reference'>
73
+ type CalculatePosOptions = Pick<Required<OverlayOptions<any>>, 'position' | 'target' | 'reference' | 'alignment'>
76
74
  & { overlay: HTMLElement }
77
75
 
78
76
  const animationDurationMS = 300
@@ -82,11 +80,28 @@ function hasMargins(element: HTMLElement) {
82
80
  return s.margin || s.marginTop || s.marginBottom || s.marginLeft || s.marginRight
83
81
  }
84
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
+
85
99
  function calculatePosition({
86
100
  overlay,
87
101
  reference,
88
102
  target,
89
103
  position: relativePosition,
104
+ alignment,
90
105
  }: CalculatePosOptions): Position & { overlayWidth: number, overlayHeight: number } {
91
106
  const overlayDimensions = overlay.getBoundingClientRect()
92
107
  if (hasMargins(overlay)) {
@@ -111,18 +126,18 @@ function calculatePosition({
111
126
  switch (relativePosition) {
112
127
  case 'top':
113
128
  referencePosition.top = elementDimensions.top + window.scrollY
114
- referencePosition.left = elementDimensions.left + elementDimensions.width / 2 + window.scrollX
129
+ referencePosition.left = calculateAlignment(alignment, elementDimensions.left, elementDimensions.width, window.scrollX)
115
130
  break
116
131
  case 'bottom':
117
132
  referencePosition.top = elementDimensions.bottom + window.scrollY
118
- referencePosition.left = elementDimensions.left + elementDimensions.width / 2 + window.scrollX
133
+ referencePosition.left = calculateAlignment(alignment, elementDimensions.left, elementDimensions.width, window.scrollX)
119
134
  break
120
135
  case 'left':
121
- referencePosition.top = elementDimensions.top + elementDimensions.height / 2 + window.scrollY
136
+ referencePosition.top = calculateAlignment(alignment, elementDimensions.top, elementDimensions.height, window.scrollY)
122
137
  referencePosition.left = elementDimensions.left + window.scrollX
123
138
  break
124
139
  case 'right':
125
- referencePosition.top = elementDimensions.top + elementDimensions.height / 2 + window.scrollY
140
+ referencePosition.top = calculateAlignment(alignment, elementDimensions.top, elementDimensions.height, window.scrollY)
126
141
  referencePosition.left = elementDimensions.right + window.scrollX
127
142
  }
128
143
  }
@@ -130,17 +145,17 @@ function calculatePosition({
130
145
  switch (relativePosition) {
131
146
  case 'top':
132
147
  position.top -= overlayDimensions.height
133
- position.left -= overlayDimensions.width / 2
148
+ position.left -= calculateAlignmentOffset(alignment, overlayDimensions.width)
134
149
  break
135
150
  case 'bottom':
136
- position.left -= overlayDimensions.width / 2
151
+ position.left -= calculateAlignmentOffset(alignment, overlayDimensions.width)
137
152
  break
138
153
  case 'left':
139
- position.top -= overlayDimensions.height / 2
154
+ position.top -= calculateAlignmentOffset(alignment, overlayDimensions.height)
140
155
  position.left -= overlayDimensions.width
141
156
  break
142
157
  case 'right':
143
- position.top -= overlayDimensions.height / 2
158
+ position.top -= calculateAlignmentOffset(alignment, overlayDimensions.height)
144
159
  }
145
160
  return position
146
161
  }
@@ -210,6 +225,47 @@ function setElementAttributes(element: HTMLElement, attributes: Record<string, a
210
225
  }
211
226
  }
212
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
+
213
269
  /**
214
270
  * Appends a new HTML Element to the tag "body". This element is absolutely positioned and its position is calculated according to the
215
271
  * options passed as parameter.
@@ -219,8 +275,9 @@ function setElementAttributes(element: HTMLElement, attributes: Record<string, a
219
275
  * @returns an object with two keys: "overlay" (the HTML Element created) and "hide" (a function to remove the element from the document).
220
276
  */
221
277
  export function showOverlay<T extends keyof HTMLTag = 'div'>(
222
- { tag, content, target, reference = 'element', position = 'top', attributes }: OverlayOptions<T>,
278
+ { tag, content, target, reference = 'element', position = 'top', alignment = 'center', attributes }: OverlayOptions<T>,
223
279
  ) {
280
+ let removeScrollEffects: (() => void) | undefined
224
281
  const overlay = document.createElement(tag || 'div')
225
282
  overlay.style = `z-index: 9999; pointer-events: none; position: absolute; opacity: 0; transition: opacity ${animationDurationMS / 1000}s; ${styleObjectToCssString(attributes?.style)}`
226
283
  overlay.inert = true
@@ -238,15 +295,30 @@ export function showOverlay<T extends keyof HTMLTag = 'div'>(
238
295
  document.body.removeChild(overlay)
239
296
  }
240
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
+
241
312
  setTimeout(() => {
242
313
  const overlayPos = getSafeOverlayPosition(
243
- { overlay, reference, target },
314
+ { alignment, overlay, reference, target },
244
315
  [position, invert(position), ...oppositeAxis(position)],
245
316
  )
246
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)}`
247
318
  if (attributes && 'inert' in attributes && attributes.inert) overlay.style.pointerEvents = 'none'
248
319
  else overlay.inert = false
249
- overlay.classList.add(overlayPos.relativeTo)
320
+ overlay.classList.add(overlayPos.relativeTo, `align-${alignment}`)
321
+ removeScrollEffects = attachScrollEffects(target, overlay, hide)
250
322
  }, 0)
251
323
 
252
324
  return {
@@ -258,17 +330,7 @@ export function showOverlay<T extends keyof HTMLTag = 'div'>(
258
330
  * Removes the overlay element.
259
331
  * @returns a promise that completes when the element is fully removed (after any animation).
260
332
  */
261
- hide: () => new Promise<void>((resolve) => {
262
- overlay.style.opacity = '0'
263
- overlay.style.pointerEvents = 'none'
264
- overlay.inert = true
265
- setTimeout(() => {
266
- try {
267
- unmount()
268
- } catch { /* empty */ }
269
- resolve()
270
- }, animationDurationMS)
271
- }),
333
+ hide,
272
334
  /**
273
335
  * Returns a promise that resolves as soon as the overlay finishes the animation to show up.
274
336
  */
package/src/types.ts CHANGED
@@ -180,15 +180,15 @@ export interface CitricController {
180
180
  /**
181
181
  * A function to run whenever the component Button is clicked.
182
182
  * @param event the click event.
183
- * @param analytics true if analytics are enabled for this button, false otherwise.
183
+ * @param metadata metadata of the button.
184
184
  */
185
- onClickButton?: (event: React.MouseEvent<HTMLButtonElement>, analytics: boolean) => void,
185
+ onClickButton?: (event: React.MouseEvent<HTMLButtonElement>, metadata: any) => void,
186
186
  /**
187
187
  * A function to run whenever the component Link is clicked.
188
188
  * @param event the click event.
189
- * @param analytics true if analytics are enabled for this link, false otherwise.
189
+ * @param metadata metadata of the anchor.
190
190
  */
191
- onClickLink?: (event: React.MouseEvent<HTMLAnchorElement>, analytics: boolean) => void,
191
+ onClickLink?: (event: React.MouseEvent<HTMLAnchorElement>, metadata: any) => void,
192
192
  /**
193
193
  * A custom renderer for error feedbacks.
194
194
  *