@proyecto-viviana/solidaria-components 0.2.5 → 0.3.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 (225) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +39 -272
  3. package/dist/ActionBar.d.ts +79 -0
  4. package/dist/ActionBar.d.ts.map +1 -0
  5. package/dist/ActionGroup.d.ts +74 -0
  6. package/dist/ActionGroup.d.ts.map +1 -0
  7. package/dist/Alert.d.ts +70 -0
  8. package/dist/Alert.d.ts.map +1 -0
  9. package/dist/Autocomplete.d.ts +5 -5
  10. package/dist/Autocomplete.d.ts.map +1 -1
  11. package/dist/Breadcrumbs.d.ts +27 -8
  12. package/dist/Breadcrumbs.d.ts.map +1 -1
  13. package/dist/Button.d.ts +28 -5
  14. package/dist/Button.d.ts.map +1 -1
  15. package/dist/Calendar.d.ts +51 -7
  16. package/dist/Calendar.d.ts.map +1 -1
  17. package/dist/Checkbox.d.ts +33 -8
  18. package/dist/Checkbox.d.ts.map +1 -1
  19. package/dist/Collection.d.ts +130 -0
  20. package/dist/Collection.d.ts.map +1 -0
  21. package/dist/Color.d.ts +210 -9
  22. package/dist/Color.d.ts.map +1 -1
  23. package/dist/ColorEditor.d.ts +42 -0
  24. package/dist/ColorEditor.d.ts.map +1 -0
  25. package/dist/ComboBox.d.ts +146 -16
  26. package/dist/ComboBox.d.ts.map +1 -1
  27. package/dist/ContextualHelpTrigger.d.ts +40 -0
  28. package/dist/ContextualHelpTrigger.d.ts.map +1 -0
  29. package/dist/DateField.d.ts +35 -8
  30. package/dist/DateField.d.ts.map +1 -1
  31. package/dist/DatePicker.d.ts +101 -5
  32. package/dist/DatePicker.d.ts.map +1 -1
  33. package/dist/DateRangePickerContext.d.ts +30 -0
  34. package/dist/DateRangePickerContext.d.ts.map +1 -0
  35. package/dist/Dialog.d.ts +5 -5
  36. package/dist/Dialog.d.ts.map +1 -1
  37. package/dist/Disclosure.d.ts +25 -5
  38. package/dist/Disclosure.d.ts.map +1 -1
  39. package/dist/DragAndDrop.d.ts +80 -0
  40. package/dist/DragAndDrop.d.ts.map +1 -0
  41. package/dist/DragPreview.d.ts +14 -0
  42. package/dist/DragPreview.d.ts.map +1 -0
  43. package/dist/DropZone.d.ts +27 -0
  44. package/dist/DropZone.d.ts.map +1 -0
  45. package/dist/FieldError.d.ts +27 -0
  46. package/dist/FieldError.d.ts.map +1 -0
  47. package/dist/FileTrigger.d.ts +26 -0
  48. package/dist/FileTrigger.d.ts.map +1 -0
  49. package/dist/Focusable.d.ts +27 -0
  50. package/dist/Focusable.d.ts.map +1 -0
  51. package/dist/Form.d.ts +41 -0
  52. package/dist/Form.d.ts.map +1 -0
  53. package/dist/GridList.d.ts +69 -10
  54. package/dist/GridList.d.ts.map +1 -1
  55. package/dist/HiddenDateInput.d.ts +26 -0
  56. package/dist/HiddenDateInput.d.ts.map +1 -0
  57. package/dist/HiddenTimeInput.d.ts +25 -0
  58. package/dist/HiddenTimeInput.d.ts.map +1 -0
  59. package/dist/Icon.d.ts +57 -0
  60. package/dist/Icon.d.ts.map +1 -0
  61. package/dist/Keyboard.d.ts +13 -0
  62. package/dist/Keyboard.d.ts.map +1 -0
  63. package/dist/Landmark.d.ts +3 -3
  64. package/dist/Landmark.d.ts.map +1 -1
  65. package/dist/Link.d.ts +10 -4
  66. package/dist/Link.d.ts.map +1 -1
  67. package/dist/ListBox.d.ts +73 -11
  68. package/dist/ListBox.d.ts.map +1 -1
  69. package/dist/ListDropTargetDelegate.d.ts +38 -0
  70. package/dist/ListDropTargetDelegate.d.ts.map +1 -0
  71. package/dist/Menu.d.ts +79 -10
  72. package/dist/Menu.d.ts.map +1 -1
  73. package/dist/Meter.d.ts +4 -4
  74. package/dist/Meter.d.ts.map +1 -1
  75. package/dist/Modal.d.ts +6 -4
  76. package/dist/Modal.d.ts.map +1 -1
  77. package/dist/NumberField.d.ts +10 -12
  78. package/dist/NumberField.d.ts.map +1 -1
  79. package/dist/Popover.d.ts +32 -7
  80. package/dist/Popover.d.ts.map +1 -1
  81. package/dist/Pressable.d.ts +27 -0
  82. package/dist/Pressable.d.ts.map +1 -0
  83. package/dist/ProgressBar.d.ts +6 -4
  84. package/dist/ProgressBar.d.ts.map +1 -1
  85. package/dist/RadioGroup.d.ts +43 -9
  86. package/dist/RadioGroup.d.ts.map +1 -1
  87. package/dist/RangeCalendar.d.ts +39 -7
  88. package/dist/RangeCalendar.d.ts.map +1 -1
  89. package/dist/RouterProvider.d.ts +75 -0
  90. package/dist/RouterProvider.d.ts.map +1 -0
  91. package/dist/SearchField.d.ts +23 -21
  92. package/dist/SearchField.d.ts.map +1 -1
  93. package/dist/Select.d.ts +48 -7
  94. package/dist/Select.d.ts.map +1 -1
  95. package/dist/SelectionIndicator.d.ts +30 -0
  96. package/dist/SelectionIndicator.d.ts.map +1 -0
  97. package/dist/Separator.d.ts +9 -3
  98. package/dist/Separator.d.ts.map +1 -1
  99. package/dist/SharedElementTransition.d.ts +41 -0
  100. package/dist/SharedElementTransition.d.ts.map +1 -0
  101. package/dist/Slider.d.ts +15 -8
  102. package/dist/Slider.d.ts.map +1 -1
  103. package/dist/StepList.d.ts +90 -0
  104. package/dist/StepList.d.ts.map +1 -0
  105. package/dist/Switch.d.ts +11 -5
  106. package/dist/Switch.d.ts.map +1 -1
  107. package/dist/Table.d.ts +222 -19
  108. package/dist/Table.d.ts.map +1 -1
  109. package/dist/Tabs.d.ts +47 -10
  110. package/dist/Tabs.d.ts.map +1 -1
  111. package/dist/TagGroup.d.ts +22 -10
  112. package/dist/TagGroup.d.ts.map +1 -1
  113. package/dist/Text.d.ts +10 -0
  114. package/dist/Text.d.ts.map +1 -0
  115. package/dist/TextField.d.ts +19 -11
  116. package/dist/TextField.d.ts.map +1 -1
  117. package/dist/TimeField.d.ts +32 -7
  118. package/dist/TimeField.d.ts.map +1 -1
  119. package/dist/Toast.d.ts +29 -14
  120. package/dist/Toast.d.ts.map +1 -1
  121. package/dist/ToggleButton.d.ts +36 -0
  122. package/dist/ToggleButton.d.ts.map +1 -0
  123. package/dist/ToggleButtonGroup.d.ts +33 -0
  124. package/dist/ToggleButtonGroup.d.ts.map +1 -0
  125. package/dist/Toolbar.d.ts +7 -3
  126. package/dist/Toolbar.d.ts.map +1 -1
  127. package/dist/Tooltip.d.ts +58 -7
  128. package/dist/Tooltip.d.ts.map +1 -1
  129. package/dist/Tree.d.ts +102 -11
  130. package/dist/Tree.d.ts.map +1 -1
  131. package/dist/Virtualizer.d.ts +61 -0
  132. package/dist/Virtualizer.d.ts.map +1 -0
  133. package/dist/VirtualizerLayouts.d.ts +82 -0
  134. package/dist/VirtualizerLayouts.d.ts.map +1 -0
  135. package/dist/VisuallyHidden.d.ts +4 -2
  136. package/dist/VisuallyHidden.d.ts.map +1 -1
  137. package/dist/contexts.d.ts +6 -1
  138. package/dist/contexts.d.ts.map +1 -1
  139. package/dist/index.d.ts +73 -39
  140. package/dist/index.d.ts.map +1 -1
  141. package/dist/index.js +23342 -10644
  142. package/dist/index.js.map +1 -7
  143. package/dist/index.jsx +18110 -0
  144. package/dist/index.jsx.map +1 -0
  145. package/dist/useDragAndDrop.d.ts +93 -0
  146. package/dist/useDragAndDrop.d.ts.map +1 -0
  147. package/dist/utils.d.ts +8 -2
  148. package/dist/utils.d.ts.map +1 -1
  149. package/dist/virtualizer/Layout.d.ts +79 -0
  150. package/dist/virtualizer/Layout.d.ts.map +1 -0
  151. package/package.json +33 -32
  152. package/src/ActionBar.tsx +251 -0
  153. package/src/ActionGroup.tsx +277 -0
  154. package/src/Alert.tsx +152 -0
  155. package/src/Autocomplete.tsx +39 -44
  156. package/src/Breadcrumbs.tsx +227 -72
  157. package/src/Button.tsx +315 -74
  158. package/src/Calendar.tsx +347 -141
  159. package/src/Checkbox.tsx +414 -123
  160. package/src/Collection.tsx +350 -0
  161. package/src/Color.tsx +1325 -284
  162. package/src/ColorEditor.tsx +213 -0
  163. package/src/ComboBox.tsx +644 -245
  164. package/src/ContextualHelpTrigger.tsx +195 -0
  165. package/src/DateField.tsx +274 -106
  166. package/src/DatePicker.tsx +892 -111
  167. package/src/DateRangePickerContext.tsx +44 -0
  168. package/src/Dialog.tsx +173 -104
  169. package/src/Disclosure.tsx +158 -105
  170. package/src/DragAndDrop.tsx +340 -0
  171. package/src/DragPreview.tsx +47 -0
  172. package/src/DropZone.tsx +233 -0
  173. package/src/FieldError.tsx +89 -0
  174. package/src/FileTrigger.tsx +83 -0
  175. package/src/Focusable.tsx +103 -0
  176. package/src/Form.tsx +140 -0
  177. package/src/GridList.tsx +542 -128
  178. package/src/HiddenDateInput.tsx +153 -0
  179. package/src/HiddenTimeInput.tsx +133 -0
  180. package/src/Icon.tsx +133 -0
  181. package/src/Keyboard.tsx +26 -0
  182. package/src/Landmark.tsx +37 -63
  183. package/src/Link.tsx +132 -69
  184. package/src/ListBox.tsx +656 -106
  185. package/src/ListDropTargetDelegate.ts +283 -0
  186. package/src/Menu.tsx +1234 -132
  187. package/src/Meter.tsx +44 -58
  188. package/src/Modal.tsx +262 -166
  189. package/src/NumberField.tsx +267 -151
  190. package/src/Popover.tsx +452 -343
  191. package/src/Pressable.tsx +108 -0
  192. package/src/ProgressBar.tsx +54 -59
  193. package/src/RadioGroup.tsx +533 -121
  194. package/src/RangeCalendar.tsx +249 -150
  195. package/src/RouterProvider.tsx +223 -0
  196. package/src/SearchField.tsx +460 -133
  197. package/src/Select.tsx +804 -233
  198. package/src/SelectionIndicator.tsx +108 -0
  199. package/src/Separator.tsx +47 -49
  200. package/src/SharedElementTransition.tsx +264 -0
  201. package/src/Slider.tsx +148 -98
  202. package/src/StepList.tsx +272 -0
  203. package/src/Switch.tsx +93 -46
  204. package/src/Table.tsx +1551 -225
  205. package/src/Tabs.tsx +377 -123
  206. package/src/TagGroup.tsx +233 -135
  207. package/src/Text.tsx +18 -0
  208. package/src/TextField.tsx +413 -86
  209. package/src/TimeField.tsx +232 -222
  210. package/src/Toast.tsx +306 -160
  211. package/src/ToggleButton.tsx +169 -0
  212. package/src/ToggleButtonGroup.tsx +141 -0
  213. package/src/Toolbar.tsx +61 -70
  214. package/src/Tooltip.tsx +473 -116
  215. package/src/Tree.tsx +1514 -175
  216. package/src/Virtualizer.tsx +730 -0
  217. package/src/VirtualizerLayouts.ts +280 -0
  218. package/src/VisuallyHidden.tsx +32 -38
  219. package/src/contexts.ts +29 -36
  220. package/src/index.ts +972 -620
  221. package/src/useDragAndDrop.ts +367 -0
  222. package/src/utils.tsx +69 -50
  223. package/src/virtualizer/Layout.ts +192 -0
  224. package/dist/index.ssr.js +0 -9785
  225. package/dist/index.ssr.js.map +0 -7
package/src/Tooltip.tsx CHANGED
@@ -15,19 +15,19 @@ import {
15
15
  createEffect,
16
16
  onCleanup,
17
17
  Show,
18
- } from 'solid-js';
19
- import { isServer } from 'solid-js/web';
18
+ } from "solid-js";
19
+ import { isServer } from "solid-js/web";
20
20
  import {
21
21
  createTooltipTriggerState,
22
22
  type TooltipTriggerState,
23
23
  type TooltipTriggerProps as StateProps,
24
- } from '@proyecto-viviana/solid-stately';
24
+ } from "@proyecto-viviana/solid-stately";
25
25
  import {
26
26
  createTooltip,
27
27
  createTooltipTrigger,
28
28
  type TooltipTriggerProps as AriaProps,
29
29
  OverlayContainer,
30
- } from '@proyecto-viviana/solidaria';
30
+ } from "@proyecto-viviana/solidaria";
31
31
  import {
32
32
  type RenderChildren,
33
33
  type ClassNameOrFunction,
@@ -35,11 +35,7 @@ import {
35
35
  type SlotProps,
36
36
  useRenderProps,
37
37
  filterDOMProps,
38
- } from './utils';
39
-
40
- // ============================================
41
- // TYPES
42
- // ============================================
38
+ } from "./utils";
43
39
 
44
40
  export interface TooltipRenderProps {
45
41
  /** Whether the tooltip is currently entering (for animations). */
@@ -47,15 +43,36 @@ export interface TooltipRenderProps {
47
43
  /** Whether the tooltip is currently exiting (for animations). */
48
44
  isExiting: boolean;
49
45
  /** The placement of the tooltip relative to the trigger. */
50
- placement: 'top' | 'bottom' | 'left' | 'right' | null;
46
+ placement: TooltipResolvedPlacement | null;
51
47
  }
52
48
 
49
+ export type TooltipPlacement = "top" | "bottom" | "left" | "right" | "start" | "end";
50
+ export type TooltipResolvedPlacement = "top" | "bottom" | "left" | "right";
51
+
53
52
  export interface TooltipTriggerComponentProps extends StateProps, AriaProps {
54
53
  /** The children of the tooltip trigger (trigger element and tooltip). */
55
54
  children: JSX.Element;
55
+ /** The placement of the tooltip relative to the trigger. */
56
+ placement?: TooltipPlacement;
57
+ /** The placement padding between the tooltip and viewport edge. */
58
+ containerPadding?: number;
59
+ /** The additional offset along the cross axis. */
60
+ crossOffset?: number;
61
+ /** Whether the tooltip should flip when there is insufficient room. */
62
+ shouldFlip?: boolean;
56
63
  }
57
64
 
58
65
  export interface TooltipProps extends SlotProps {
66
+ /** The element id. */
67
+ id?: string;
68
+ /** Custom aria-label for the tooltip. */
69
+ "aria-label"?: string;
70
+ /** ID of an element that labels the tooltip. */
71
+ "aria-labelledby"?: string;
72
+ /** ID of an element that describes the tooltip. */
73
+ "aria-describedby"?: string;
74
+ /** ID of an element that provides details for the tooltip. */
75
+ "aria-details"?: string;
59
76
  /** The children of the tooltip. A function may be provided to receive render props. */
60
77
  children?: RenderChildren<TooltipRenderProps>;
61
78
  /** The CSS className for the element. */
@@ -67,26 +84,42 @@ export interface TooltipProps extends SlotProps {
67
84
  /** Whether the tooltip is open by default (uncontrolled). */
68
85
  defaultOpen?: boolean;
69
86
  /** The placement of the tooltip relative to the trigger. */
70
- placement?: 'top' | 'bottom' | 'left' | 'right';
71
- /** Whether to render the tooltip in a portal. */
87
+ placement?: TooltipPlacement;
88
+ /** The placement padding between the tooltip and viewport edge. */
89
+ containerPadding?: number;
90
+ /** The additional offset along the cross axis. */
91
+ crossOffset?: number;
92
+ /** Whether the tooltip should flip when there is insufficient room. */
72
93
  shouldFlip?: boolean;
94
+ /** The offset between the tooltip and trigger. */
95
+ offset?: number;
96
+ /** The arrow size used to keep the arrow overlapping the trigger. */
97
+ arrowSize?: number;
98
+ /** The padding between the arrow and tooltip edge. */
99
+ arrowBoundaryOffset?: number;
100
+ /** Whether the tooltip should be disabled. */
101
+ isDisabled?: boolean;
102
+ /** The element language. */
103
+ lang?: string;
104
+ /** The text direction. */
105
+ dir?: "ltr" | "rtl";
73
106
  }
74
107
 
75
- // ============================================
76
- // CONTEXT
77
- // ============================================
78
-
79
108
  interface TooltipTriggerContextValue {
80
109
  state: TooltipTriggerState;
81
- tooltipProps: { id: string };
110
+ tooltipProps: { readonly id: string };
111
+ setTooltipId: (id: string | undefined) => void;
82
112
  triggerRef: () => HTMLElement | null | undefined;
113
+ placement: () => TooltipPlacement | undefined;
114
+ containerPadding: () => number | undefined;
115
+ crossOffset: () => number | undefined;
116
+ shouldFlip: () => boolean | undefined;
117
+ isDisabled: () => boolean | undefined;
83
118
  }
84
119
 
85
120
  const TooltipTriggerContext = createContext<TooltipTriggerContextValue | null>(null);
86
-
87
- // ============================================
88
- // COMPONENTS
89
- // ============================================
121
+ export const TooltipContext = TooltipTriggerContext;
122
+ export const TooltipTriggerStateContext = createContext<TooltipTriggerState | null>(null);
90
123
 
91
124
  /**
92
125
  * TooltipTrigger wraps around a trigger element and a Tooltip.
@@ -103,42 +136,68 @@ const TooltipTriggerContext = createContext<TooltipTriggerContextValue | null>(n
103
136
  */
104
137
  export const TooltipTrigger: ParentComponent<TooltipTriggerComponentProps> = (props) => {
105
138
  let triggerRef: HTMLElement | null = null;
139
+ const [tooltipId, setTooltipId] = createSignal<string | undefined>();
106
140
 
107
141
  const state = createTooltipTriggerState({
108
- get delay() { return props.delay; },
109
- get closeDelay() { return props.closeDelay; },
110
- get isOpen() { return props.isOpen; },
111
- get defaultOpen() { return props.defaultOpen; },
112
- get onOpenChange() { return props.onOpenChange; },
142
+ get delay() {
143
+ return props.delay;
144
+ },
145
+ get closeDelay() {
146
+ return props.closeDelay;
147
+ },
148
+ get isOpen() {
149
+ return props.isOpen;
150
+ },
151
+ get defaultOpen() {
152
+ return props.defaultOpen;
153
+ },
154
+ get onOpenChange() {
155
+ return props.onOpenChange;
156
+ },
113
157
  });
114
158
 
115
159
  const { triggerProps, tooltipProps } = createTooltipTrigger(
116
160
  {
117
- get isDisabled() { return props.isDisabled; },
118
- get trigger() { return props.trigger; },
119
- get shouldCloseOnPress() { return props.shouldCloseOnPress; },
161
+ get isDisabled() {
162
+ return props.isDisabled;
163
+ },
164
+ get trigger() {
165
+ return props.trigger;
166
+ },
167
+ get shouldCloseOnPress() {
168
+ return props.shouldCloseOnPress;
169
+ },
170
+ get tooltipId() {
171
+ return tooltipId();
172
+ },
120
173
  },
121
174
  state,
122
- () => triggerRef
175
+ () => triggerRef,
123
176
  );
124
177
 
125
178
  const context: TooltipTriggerContextValue = {
126
179
  state,
127
180
  tooltipProps,
181
+ setTooltipId,
128
182
  triggerRef: () => triggerRef,
183
+ placement: () => props.placement,
184
+ containerPadding: () => props.containerPadding,
185
+ crossOffset: () => props.crossOffset,
186
+ shouldFlip: () => props.shouldFlip,
187
+ isDisabled: () => props.isDisabled,
129
188
  };
130
189
 
131
- // Clone children and inject trigger props into the first child
132
190
  const processChildren = () => {
133
191
  const children = props.children;
134
192
  if (Array.isArray(children)) {
135
- // First child is the trigger, rest are tooltip(s)
136
193
  const [trigger, ...rest] = children;
137
194
  return (
138
195
  <>
139
196
  <TriggerWrapper
140
197
  triggerProps={triggerProps}
141
- ref={(el) => { triggerRef = el; }}
198
+ ref={(el) => {
199
+ triggerRef = el;
200
+ }}
142
201
  >
143
202
  {trigger}
144
203
  </TriggerWrapper>
@@ -150,9 +209,11 @@ export const TooltipTrigger: ParentComponent<TooltipTriggerComponentProps> = (pr
150
209
  };
151
210
 
152
211
  return (
153
- <TooltipTriggerContext.Provider value={context}>
154
- {processChildren()}
155
- </TooltipTriggerContext.Provider>
212
+ <TooltipTriggerStateContext.Provider value={state}>
213
+ <TooltipTriggerContext.Provider value={context}>
214
+ {processChildren()}
215
+ </TooltipTriggerContext.Provider>
216
+ </TooltipTriggerStateContext.Provider>
156
217
  );
157
218
  };
158
219
 
@@ -163,13 +224,72 @@ const TriggerWrapper: ParentComponent<{
163
224
  triggerProps: JSX.HTMLAttributes<HTMLElement>;
164
225
  ref: (el: HTMLElement) => void;
165
226
  }> = (props) => {
166
- // Get the child element and clone it with trigger props
167
227
  const child = () => props.children as JSX.Element;
228
+ const [triggerElement, setTriggerElement] = createSignal<HTMLElement | null>(null);
229
+
230
+ createEffect(() => {
231
+ const element = triggerElement();
232
+ if (!element) {
233
+ return;
234
+ }
235
+
236
+ const triggerProps = props.triggerProps as Record<string, unknown>;
237
+ const describedBy = triggerProps["aria-describedby"] as string | undefined;
238
+ if (describedBy) {
239
+ element.setAttribute("aria-describedby", describedBy);
240
+ } else {
241
+ element.removeAttribute("aria-describedby");
242
+ }
243
+
244
+ const listeners: Array<[string, EventListener]> = [];
245
+ const eventProps = [
246
+ ["onFocus", "focus"],
247
+ ["onBlur", "blur"],
248
+ ["onPointerEnter", "pointerenter"],
249
+ ["onPointerLeave", "pointerleave"],
250
+ ["onPointerOver", "pointerover"],
251
+ ["onPointerOut", "pointerout"],
252
+ ["onMouseEnter", "mouseenter"],
253
+ ["onMouseLeave", "mouseleave"],
254
+ ["onTouchStart", "touchstart"],
255
+ ["onPointerDown", "pointerdown"],
256
+ ["onKeyDown", "keydown"],
257
+ ] as const;
258
+
259
+ for (const [propName, eventName] of eventProps) {
260
+ const handler = triggerProps[propName];
261
+ if (typeof handler === "function") {
262
+ const listener = handler as EventListener;
263
+ element.addEventListener(eventName, listener);
264
+ listeners.push([eventName, listener]);
265
+ }
266
+ }
267
+
268
+ onCleanup(() => {
269
+ for (const [eventName, listener] of listeners) {
270
+ element.removeEventListener(eventName, listener);
271
+ }
272
+ if (describedBy && element.getAttribute("aria-describedby") === describedBy) {
273
+ element.removeAttribute("aria-describedby");
274
+ }
275
+ });
276
+ });
168
277
 
169
278
  // We wrap in a span with display:contents to not affect layout.
170
279
  // However, display:contents makes getBoundingClientRect return zeros,
171
280
  // so we pass a ref callback that finds the first actual element child.
172
281
  const handleRef = (span: HTMLSpanElement) => {
282
+ const findElementChild = (el: Element): HTMLElement | null => {
283
+ for (const child of el.children) {
284
+ if (child instanceof HTMLElement) {
285
+ return child;
286
+ }
287
+ const found = findElementChild(child);
288
+ if (found) return found;
289
+ }
290
+ return null;
291
+ };
292
+
173
293
  // Find the first element child that has dimensions (not display:contents)
174
294
  const findVisibleChild = (el: Element): HTMLElement | null => {
175
295
  if (el instanceof HTMLElement) {
@@ -177,7 +297,6 @@ const TriggerWrapper: ParentComponent<{
177
297
  if (rect.width > 0 && rect.height > 0) {
178
298
  return el;
179
299
  }
180
- // Check children
181
300
  for (const child of el.children) {
182
301
  const found = findVisibleChild(child);
183
302
  if (found) return found;
@@ -189,31 +308,28 @@ const TriggerWrapper: ParentComponent<{
189
308
  // Use requestAnimationFrame to ensure children are rendered and have dimensions
190
309
  // This is necessary because SolidJS may not have computed child layout yet
191
310
  const resolveRef = () => {
311
+ const elementChild = findElementChild(span);
192
312
  const visibleChild = findVisibleChild(span);
193
- if (visibleChild) {
194
- props.ref(visibleChild);
195
- } else {
196
- // Fallback to span itself
197
- props.ref(span);
198
- }
313
+ setTriggerElement(elementChild ?? visibleChild ?? span);
314
+ props.ref(visibleChild ?? elementChild ?? span);
199
315
  };
200
316
 
201
- // Try immediately first (in case layout is already computed)
317
+ const elementChild = findElementChild(span);
318
+ if (elementChild) {
319
+ setTriggerElement(elementChild);
320
+ }
321
+
202
322
  const immediateChild = findVisibleChild(span);
203
323
  if (immediateChild) {
324
+ setTriggerElement(elementChild ?? immediateChild);
204
325
  props.ref(immediateChild);
205
326
  } else {
206
- // Defer to next frame when layout should be computed
207
327
  requestAnimationFrame(resolveRef);
208
328
  }
209
329
  };
210
330
 
211
331
  return (
212
- <span
213
- {...props.triggerProps}
214
- ref={handleRef}
215
- style={{ display: 'contents' }}
216
- >
332
+ <span ref={handleRef} style={{ display: "contents" }}>
217
333
  {child()}
218
334
  </span>
219
335
  );
@@ -233,26 +349,98 @@ const TriggerWrapper: ParentComponent<{
233
349
  export function Tooltip(props: TooltipProps): JSX.Element {
234
350
  const context = useContext(TooltipTriggerContext);
235
351
 
236
- // Support standalone usage
352
+ createEffect(() => {
353
+ context?.setTooltipId(props.id);
354
+ onCleanup(() => {
355
+ context?.setTooltipId(undefined);
356
+ });
357
+ });
358
+
237
359
  const localState = createTooltipTriggerState({
238
- get isOpen() { return props.isOpen; },
239
- get defaultOpen() { return props.defaultOpen; },
360
+ get isOpen() {
361
+ return props.isOpen;
362
+ },
363
+ get defaultOpen() {
364
+ return props.defaultOpen;
365
+ },
240
366
  });
241
367
 
242
368
  const state = () => context?.state ?? localState;
243
- const placement = () => props.placement ?? 'top';
369
+ const placement = () => props.placement ?? context?.placement() ?? "top";
370
+ const containerPadding = () => props.containerPadding ?? context?.containerPadding() ?? 12;
371
+ const crossOffset = () => props.crossOffset ?? context?.crossOffset() ?? 0;
372
+ const shouldFlip = () => props.shouldFlip ?? context?.shouldFlip() ?? true;
373
+ const offset = () => props.offset ?? 9;
374
+ const arrowSize = () => props.arrowSize ?? 0;
375
+ const arrowBoundaryOffset = () => props.arrowBoundaryOffset ?? 0;
376
+ const isDisabled = () => props.isDisabled ?? context?.isDisabled() ?? false;
377
+
378
+ const isOpen = () => !isDisabled() && state().isOpen();
379
+
380
+ // Exit animation state machine: 'closed' | 'open' | 'exiting'
381
+ // Keeps the tooltip mounted during exit animation so CSS transitions can play.
382
+ const [exitState, setExitState] = createSignal<"closed" | "open" | "exiting">(
383
+ isOpen() ? "open" : "closed",
384
+ );
385
+
386
+ createEffect(() => {
387
+ const open = isOpen();
388
+ const current = exitState();
389
+ if (current === "open" && !open) {
390
+ setExitState("exiting");
391
+ } else if ((current === "closed" || current === "exiting") && open) {
392
+ setExitState("open");
393
+ }
394
+ });
395
+
396
+ // Signal for the tooltip ref so we can observe exit animations
397
+ const [tooltipEl, setTooltipEl] = createSignal<HTMLDivElement | null>(null);
398
+
399
+ // When exiting, wait for CSS animations to finish, then set state to closed
400
+ createEffect(() => {
401
+ if (exitState() !== "exiting") return;
402
+ const el = tooltipEl();
403
+ if (!el || !("getAnimations" in el)) {
404
+ setExitState("closed");
405
+ return;
406
+ }
407
+ const animations = el.getAnimations();
408
+ if (animations.length === 0) {
409
+ setExitState("closed");
410
+ return;
411
+ }
412
+ let canceled = false;
413
+ Promise.all(animations.map((a) => a.finished))
414
+ .then(() => {
415
+ if (!canceled) setExitState((s) => (s === "exiting" ? "closed" : s));
416
+ })
417
+ .catch(() => {
418
+ if (!canceled) setExitState((s) => (s === "exiting" ? "closed" : s));
419
+ });
420
+ onCleanup(() => {
421
+ canceled = true;
422
+ });
423
+ });
244
424
 
245
- // Only render when open
246
- const isOpen = () => state().isOpen();
425
+ const shouldRender = () => isOpen() || exitState() === "exiting";
426
+ const isExiting = () => exitState() === "exiting";
247
427
 
248
428
  return (
249
- <Show when={isOpen()}>
429
+ <Show when={shouldRender()}>
250
430
  <TooltipContent
251
431
  {...props}
252
432
  state={state()}
253
433
  contextTooltipProps={context?.tooltipProps ?? {}}
254
434
  placement={placement()}
435
+ containerPadding={containerPadding()}
436
+ crossOffset={crossOffset()}
437
+ shouldFlip={shouldFlip()}
438
+ offset={offset()}
439
+ arrowSize={arrowSize()}
440
+ arrowBoundaryOffset={arrowBoundaryOffset()}
255
441
  triggerRef={context?.triggerRef ?? (() => null)}
442
+ isExiting={isExiting()}
443
+ onTooltipRef={setTooltipEl}
256
444
  />
257
445
  </Show>
258
446
  );
@@ -264,10 +452,18 @@ export function Tooltip(props: TooltipProps): JSX.Element {
264
452
  function TooltipContent(
265
453
  props: TooltipProps & {
266
454
  state: TooltipTriggerState;
267
- contextTooltipProps: { id?: string };
268
- placement: 'top' | 'bottom' | 'left' | 'right';
455
+ contextTooltipProps: { readonly id?: string };
456
+ placement: TooltipPlacement;
457
+ containerPadding: number;
458
+ crossOffset: number;
459
+ shouldFlip: boolean;
460
+ offset: number;
461
+ arrowSize: number;
462
+ arrowBoundaryOffset: number;
269
463
  triggerRef: () => HTMLElement | null | undefined;
270
- }
464
+ isExiting: boolean;
465
+ onTooltipRef: (el: HTMLDivElement | null) => void;
466
+ },
271
467
  ): JSX.Element {
272
468
  if (isServer) {
273
469
  return null as unknown as JSX.Element;
@@ -276,20 +472,57 @@ function TooltipContent(
276
472
  let tooltipRef!: HTMLDivElement;
277
473
  const { tooltipProps: ariaTooltipProps } = createTooltip({}, props.state);
278
474
 
279
- // Signal to track position styles
280
475
  // Start visible at 0,0 and update position asynchronously
281
476
  // This ensures the tooltip is immediately accessible (for screen readers and tests)
282
477
  // while the visual position gets calculated
283
478
  const [positionStyles, setPositionStyles] = createSignal({
284
- top: '0px',
285
- left: '0px',
286
- visibility: 'visible' as 'hidden' | 'visible',
479
+ top: "0px",
480
+ left: "0px",
481
+ visibility: "visible" as "hidden" | "visible",
482
+ });
483
+ const [renderedPlacement, setRenderedPlacement] = createSignal<TooltipResolvedPlacement>(
484
+ resolvePlacement(props.placement),
485
+ );
486
+
487
+ // Enter animation state: starts true on mount, clears after first animation frame.
488
+ // Uses getAnimations() to detect CSS animations/transitions - if none exist (JSDOM,
489
+ // no CSS defined, reduced-motion), clears immediately.
490
+ const [isEntering, setIsEntering] = createSignal(true);
491
+
492
+ createEffect(() => {
493
+ if (!isEntering()) return;
494
+ if (!tooltipRef || !("getAnimations" in tooltipRef)) {
495
+ setIsEntering(false);
496
+ return;
497
+ }
498
+ // Cancel any premature CSS transitions triggered before layout
499
+ for (const anim of tooltipRef.getAnimations()) {
500
+ if (anim instanceof CSSTransition) {
501
+ anim.cancel();
502
+ }
503
+ }
504
+ const animations = tooltipRef.getAnimations();
505
+ if (animations.length === 0) {
506
+ setIsEntering(false);
507
+ return;
508
+ }
509
+ let canceled = false;
510
+ Promise.all(animations.map((a) => a.finished))
511
+ .then(() => {
512
+ if (!canceled) setIsEntering(false);
513
+ })
514
+ .catch(() => {
515
+ if (!canceled) setIsEntering(false);
516
+ });
517
+ onCleanup(() => {
518
+ canceled = true;
519
+ });
287
520
  });
288
521
 
289
522
  const values = createMemo<TooltipRenderProps>(() => ({
290
- isEntering: false, // TODO: animation support
291
- isExiting: false,
292
- placement: props.placement,
523
+ isEntering: isEntering(),
524
+ isExiting: props.isExiting,
525
+ placement: renderedPlacement(),
293
526
  }));
294
527
 
295
528
  const renderProps = useRenderProps(
@@ -297,13 +530,13 @@ function TooltipContent(
297
530
  class: props.class,
298
531
  style: props.style,
299
532
  children: props.children,
300
- defaultClassName: 'solidaria-Tooltip',
533
+ defaultClassName: "solidaria-Tooltip",
301
534
  },
302
- values
535
+ values,
303
536
  );
304
537
 
305
- // Calculate position based on trigger element
306
- // Using position: fixed so we use viewport coordinates directly from getBoundingClientRect
538
+ // Position the overlay in document coordinates, matching React Aria's
539
+ // absolute overlay positioning when the portal container is the document.
307
540
  // Returns true if position was successfully updated, false if we need to retry
308
541
  const updatePosition = (): boolean => {
309
542
  const triggerEl = props.triggerRef();
@@ -320,98 +553,166 @@ function TooltipContent(
320
553
  // when the element might be positioned off-screen initially
321
554
  const tooltipWidth = tooltipRef.offsetWidth;
322
555
  const tooltipHeight = tooltipRef.offsetHeight;
323
- const offset = 8; // Gap between trigger and tooltip
556
+ const viewportWidth = window.innerWidth;
557
+ const viewportHeight = window.innerHeight;
558
+ const containerPadding = props.containerPadding;
559
+ const crossOffset = props.crossOffset;
560
+ const placement = maybeFlipPlacement(
561
+ resolvePlacement(props.placement),
562
+ triggerRect,
563
+ tooltipWidth,
564
+ tooltipHeight,
565
+ viewportWidth,
566
+ viewportHeight,
567
+ containerPadding,
568
+ props.offset,
569
+ props.shouldFlip,
570
+ );
324
571
 
325
572
  let top = 0;
326
573
  let left = 0;
327
574
 
328
- // Using viewport coordinates for position: fixed
329
- switch (props.placement) {
330
- case 'top':
331
- top = triggerRect.top - tooltipHeight - offset;
332
- left = triggerRect.left + (triggerRect.width - tooltipWidth) / 2;
575
+ switch (placement) {
576
+ case "top":
577
+ top = triggerRect.top - tooltipHeight - props.offset;
578
+ left = triggerRect.left + (triggerRect.width - tooltipWidth) / 2 + crossOffset;
333
579
  break;
334
- case 'bottom':
335
- top = triggerRect.bottom + offset;
336
- left = triggerRect.left + (triggerRect.width - tooltipWidth) / 2;
580
+ case "bottom":
581
+ top = triggerRect.bottom + props.offset;
582
+ left = triggerRect.left + (triggerRect.width - tooltipWidth) / 2 + crossOffset;
337
583
  break;
338
- case 'left':
339
- top = triggerRect.top + (triggerRect.height - tooltipHeight) / 2;
340
- left = triggerRect.left - tooltipWidth - offset;
584
+ case "left":
585
+ top = triggerRect.top + (triggerRect.height - tooltipHeight) / 2 + crossOffset;
586
+ left = triggerRect.left - tooltipWidth - props.offset;
341
587
  break;
342
- case 'right':
343
- top = triggerRect.top + (triggerRect.height - tooltipHeight) / 2;
344
- left = triggerRect.right + offset;
588
+ case "right":
589
+ top = triggerRect.top + (triggerRect.height - tooltipHeight) / 2 + crossOffset;
590
+ left = triggerRect.right + props.offset;
345
591
  break;
346
592
  }
347
593
 
594
+ if (placement === "top" || placement === "bottom") {
595
+ left = clamp(
596
+ left,
597
+ triggerRect.left - tooltipWidth + props.arrowSize + props.arrowBoundaryOffset,
598
+ triggerRect.left + triggerRect.width - props.arrowSize - props.arrowBoundaryOffset,
599
+ );
600
+ left = clamp(left, containerPadding, viewportWidth - tooltipWidth - containerPadding);
601
+ } else {
602
+ top = clamp(
603
+ top,
604
+ triggerRect.top - tooltipHeight + props.arrowSize + props.arrowBoundaryOffset,
605
+ triggerRect.top + triggerRect.height - props.arrowSize - props.arrowBoundaryOffset,
606
+ );
607
+ top = clamp(top, containerPadding, viewportHeight - tooltipHeight - containerPadding);
608
+ }
609
+ setRenderedPlacement(placement);
348
610
  setPositionStyles({
349
- top: `${top}px`,
350
- left: `${left}px`,
351
- visibility: 'visible',
611
+ top: `${Math.floor(top + window.scrollY)}px`,
612
+ left: `${Math.floor(left + window.scrollX)}px`,
613
+ visibility: "visible",
352
614
  });
353
615
 
354
616
  return true;
355
617
  };
356
618
 
357
- // Set up positioning effect - runs when trigger ref is available
619
+ // Set up positioning and scroll-close effects. Positioning retries while the
620
+ // trigger ref resolves, and pending rAF/setTimeout IDs are canceled on cleanup.
358
621
  createEffect(() => {
359
- const trigger = props.triggerRef();
360
- if (!trigger) return;
622
+ // Track positioning inputs synchronously so updates from controlled route
623
+ // props reschedule measurement even though layout reads happen in rAF.
624
+ props.placement;
625
+ props.containerPadding;
626
+ props.crossOffset;
627
+ props.shouldFlip;
628
+ props.offset;
629
+ props.arrowSize;
630
+ props.arrowBoundaryOffset;
361
631
 
362
- // Initial position calculation - use requestAnimationFrame to ensure
363
- // the element is rendered and has proper dimensions
364
- // We may need multiple frames if the trigger ref hasn't resolved yet
365
632
  let retryCount = 0;
366
633
  const maxRetries = 5;
634
+ let pendingRaf = 0;
635
+ let pendingTimeout = 0;
367
636
 
368
637
  const tryUpdatePosition = () => {
638
+ pendingRaf = 0;
639
+ pendingTimeout = 0;
369
640
  const success = updatePosition();
370
641
  if (!success && retryCount < maxRetries) {
371
642
  retryCount++;
372
- // In JSDOM, requestAnimationFrame may not trigger layout properly
373
- // Use setTimeout for more reliable deferral across environments
374
- setTimeout(tryUpdatePosition, 16); // ~60fps
643
+ pendingTimeout = window.setTimeout(tryUpdatePosition, 16);
375
644
  }
376
- // If all retries fail, tooltip stays at 0,0 (test environments)
377
- // The tooltip is visible by default, so it remains accessible
378
645
  };
379
646
 
380
- // Initial attempt - use rAF for real browsers, then fall back to timeout retries
381
- requestAnimationFrame(tryUpdatePosition);
647
+ pendingRaf = requestAnimationFrame(tryUpdatePosition);
648
+
649
+ const closeOnScroll = (event: Event) => {
650
+ const trigger = props.triggerRef();
651
+ const target = event.target;
652
+ if (!trigger || (target instanceof Node && !target.contains(trigger))) {
653
+ return;
654
+ }
655
+ if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) {
656
+ return;
657
+ }
382
658
 
383
- // Update on scroll/resize
384
- window.addEventListener('scroll', updatePosition, true);
385
- window.addEventListener('resize', updatePosition);
659
+ props.state.close(true);
660
+ };
661
+
662
+ window.addEventListener("scroll", closeOnScroll, true);
663
+ window.addEventListener("resize", updatePosition);
386
664
 
387
665
  onCleanup(() => {
388
- window.removeEventListener('scroll', updatePosition, true);
389
- window.removeEventListener('resize', updatePosition);
666
+ if (pendingRaf) cancelAnimationFrame(pendingRaf);
667
+ if (pendingTimeout) clearTimeout(pendingTimeout);
668
+ window.removeEventListener("scroll", closeOnScroll, true);
669
+ window.removeEventListener("resize", updatePosition);
390
670
  });
391
671
  });
392
672
 
393
- // Filter to only valid DOM props (aria-*, data-*, events)
394
- const domProps = filterDOMProps(props);
673
+ const domProps = filterDOMProps(props, { global: true });
674
+ const tooltipId = () => props.contextTooltipProps.id ?? (domProps as { id?: string }).id;
395
675
 
396
676
  // Extract ref from ariaTooltipProps to avoid type conflicts (SolidJS ref types are element-specific)
397
677
  const { ref: _ariaRef, ...cleanAriaProps } = ariaTooltipProps as Record<string, unknown>;
398
678
 
679
+ const setRef = (el: HTMLDivElement) => {
680
+ tooltipRef = el;
681
+ if (!props.dir) {
682
+ el.dir = getDocumentDirection();
683
+ }
684
+ if (!props.lang) {
685
+ const documentLang = document.documentElement.lang;
686
+ const navigatorLang = typeof navigator !== "undefined" ? navigator.language : "";
687
+ el.lang = documentLang.includes("-")
688
+ ? documentLang
689
+ : navigatorLang || documentLang || "en-US";
690
+ }
691
+ props.onTooltipRef(el);
692
+ };
693
+
694
+ onCleanup(() => {
695
+ props.onTooltipRef(null);
696
+ });
697
+
399
698
  return (
400
699
  <OverlayContainer>
401
700
  <div
402
701
  {...domProps}
403
- {...props.contextTooltipProps}
404
702
  {...cleanAriaProps}
703
+ id={tooltipId()}
405
704
  role="tooltip"
406
- ref={tooltipRef}
705
+ ref={setRef}
407
706
  class={renderProps.class()}
408
707
  style={{
409
- position: 'fixed',
410
- 'z-index': 100000,
708
+ position: "absolute",
709
+ "z-index": 100000,
411
710
  ...positionStyles(),
412
711
  ...renderProps.style(),
413
712
  }}
414
- data-placement={props.placement}
713
+ data-placement={renderedPlacement()}
714
+ data-entering={isEntering() || undefined}
715
+ data-exiting={props.isExiting || undefined}
415
716
  >
416
717
  {renderProps.renderChildren()}
417
718
  </div>
@@ -419,5 +720,61 @@ function TooltipContent(
419
720
  );
420
721
  }
421
722
 
422
- // Re-export types
723
+ function resolvePlacement(placement: TooltipPlacement): TooltipResolvedPlacement {
724
+ if (placement === "start") {
725
+ return getDocumentDirection() === "rtl" ? "right" : "left";
726
+ }
727
+ if (placement === "end") {
728
+ return getDocumentDirection() === "rtl" ? "left" : "right";
729
+ }
730
+ return placement;
731
+ }
732
+
733
+ function getDocumentDirection(): "ltr" | "rtl" {
734
+ if (typeof document === "undefined") {
735
+ return "ltr";
736
+ }
737
+
738
+ return document.documentElement.dir === "rtl" ? "rtl" : "ltr";
739
+ }
740
+
741
+ function maybeFlipPlacement(
742
+ placement: TooltipResolvedPlacement,
743
+ triggerRect: DOMRect,
744
+ tooltipWidth: number,
745
+ tooltipHeight: number,
746
+ viewportWidth: number,
747
+ viewportHeight: number,
748
+ containerPadding: number,
749
+ offset: number,
750
+ shouldFlip: boolean,
751
+ ): TooltipResolvedPlacement {
752
+ if (!shouldFlip) {
753
+ return placement;
754
+ }
755
+
756
+ switch (placement) {
757
+ case "top":
758
+ return triggerRect.top - tooltipHeight - offset < containerPadding ? "bottom" : placement;
759
+ case "bottom":
760
+ return triggerRect.bottom + tooltipHeight + offset > viewportHeight - containerPadding
761
+ ? "top"
762
+ : placement;
763
+ case "left":
764
+ return triggerRect.left - tooltipWidth - offset < containerPadding ? "right" : placement;
765
+ case "right":
766
+ return triggerRect.right + tooltipWidth + offset > viewportWidth - containerPadding
767
+ ? "left"
768
+ : placement;
769
+ }
770
+ }
771
+
772
+ function clamp(value: number, min: number, max: number): number {
773
+ if (max < min) {
774
+ return min;
775
+ }
776
+
777
+ return Math.min(Math.max(value, min), max);
778
+ }
779
+
423
780
  export type { TooltipTriggerState };