@tcn/ui 0.2.0 → 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 (247) hide show
  1. package/dist/divider.module-FptFV0PX.js +5 -0
  2. package/dist/divider.module-FptFV0PX.js.map +1 -0
  3. package/dist/form/field/field.js +1 -1
  4. package/dist/frame.css +1 -0
  5. package/dist/inputs/color_input/color_input.js +1 -1
  6. package/dist/inputs/color_input/color_input.js.map +1 -1
  7. package/dist/inputs/color_input/color_picker.js +1 -1
  8. package/dist/inputs/combo_box/combo_box.js +1 -1
  9. package/dist/inputs/date_picker/date_picker.js +1 -1
  10. package/dist/inputs/date_picker/date_picker_input.js +2 -2
  11. package/dist/inputs/date_picker/date_picker_input.js.map +1 -1
  12. package/dist/inputs/date_picker/date_picker_year_input.js +1 -1
  13. package/dist/inputs/date_picker/date_picker_year_input.js.map +1 -1
  14. package/dist/inputs/date_picker/date_picker_year_selector.js +1 -1
  15. package/dist/inputs/mask_input/key_capture_input.js +1 -1
  16. package/dist/inputs/mask_input/mask_input.js +1 -1
  17. package/dist/inputs/multiselect/multiselect.js +1 -1
  18. package/dist/inputs/phone_number_input/phone_number_input.js +1 -1
  19. package/dist/inputs/select/select.js +1 -1
  20. package/dist/inputs/slider/slider.js +1 -1
  21. package/dist/inputs/suggestions/suggestion_list.js +2 -2
  22. package/dist/inputs/suggestions/suggestion_list.js.map +1 -1
  23. package/dist/inputs/switch/switch.js +1 -1
  24. package/dist/inputs/unit_input/unit_input.js +1 -1
  25. package/dist/layouts/divider/divider.js +24 -23
  26. package/dist/layouts/divider/divider.js.map +1 -1
  27. package/dist/layouts/index.d.ts +6 -5
  28. package/dist/layouts/index.d.ts.map +1 -1
  29. package/dist/layouts/index.js +28 -26
  30. package/dist/layouts/index.js.map +1 -1
  31. package/dist/layouts/scaffold/scaffold.d.ts +9 -0
  32. package/dist/layouts/scaffold/scaffold.d.ts.map +1 -0
  33. package/dist/layouts/scaffold/scaffold.js +55 -0
  34. package/dist/layouts/scaffold/scaffold.js.map +1 -0
  35. package/dist/modal.css +1 -1
  36. package/dist/overlay/frame/frame.d.ts.map +1 -1
  37. package/dist/overlay/frame/frame.js +22 -5
  38. package/dist/overlay/frame/frame.js.map +1 -1
  39. package/dist/overlay/index.d.ts +9 -2
  40. package/dist/overlay/index.d.ts.map +1 -1
  41. package/dist/overlay/index.js +22 -10
  42. package/dist/overlay/index.js.map +1 -1
  43. package/dist/overlay/menu/menu.d.ts +1 -1
  44. package/dist/overlay/menu/menu.d.ts.map +1 -1
  45. package/dist/overlay/menu/menu.js +2 -2
  46. package/dist/overlay/menu/menu.js.map +1 -1
  47. package/dist/overlay/popper/base/base_popper.d.ts +11 -0
  48. package/dist/overlay/popper/base/base_popper.d.ts.map +1 -0
  49. package/dist/overlay/popper/base/base_popper.js +27 -0
  50. package/dist/overlay/popper/base/base_popper.js.map +1 -0
  51. package/dist/overlay/popper/base/dismissal_decorator.d.ts +16 -0
  52. package/dist/overlay/popper/base/dismissal_decorator.d.ts.map +1 -0
  53. package/dist/overlay/popper/base/dismissal_decorator.js +69 -0
  54. package/dist/overlay/popper/base/dismissal_decorator.js.map +1 -0
  55. package/dist/overlay/popper/context_popper.d.ts +11 -0
  56. package/dist/overlay/popper/context_popper.d.ts.map +1 -0
  57. package/dist/overlay/popper/context_popper.js +33 -0
  58. package/dist/overlay/popper/context_popper.js.map +1 -0
  59. package/dist/overlay/popper/element_popper.d.ts +7 -0
  60. package/dist/overlay/popper/element_popper.d.ts.map +1 -0
  61. package/dist/overlay/popper/element_popper.js +33 -0
  62. package/dist/overlay/popper/element_popper.js.map +1 -0
  63. package/dist/overlay/popper/hooks/use_context_trigger.d.ts +7 -0
  64. package/dist/overlay/popper/hooks/use_context_trigger.d.ts.map +1 -0
  65. package/dist/overlay/popper/hooks/use_context_trigger.js +31 -0
  66. package/dist/overlay/popper/hooks/use_context_trigger.js.map +1 -0
  67. package/dist/overlay/popper/hooks/use_hover_trigger.d.ts +6 -0
  68. package/dist/overlay/popper/hooks/use_hover_trigger.d.ts.map +1 -0
  69. package/dist/overlay/popper/hooks/use_hover_trigger.js +17 -0
  70. package/dist/overlay/popper/hooks/use_hover_trigger.js.map +1 -0
  71. package/dist/overlay/popper/hooks/use_restore_focus.d.ts +2 -0
  72. package/dist/overlay/popper/hooks/use_restore_focus.d.ts.map +1 -0
  73. package/dist/overlay/popper/hooks/use_restore_focus.js +18 -0
  74. package/dist/overlay/popper/hooks/use_restore_focus.js.map +1 -0
  75. package/dist/overlay/popper/legacy/popper.d.ts.map +1 -0
  76. package/dist/overlay/popper/{popper.js → legacy/popper.js} +6 -6
  77. package/dist/overlay/popper/legacy/popper.js.map +1 -0
  78. package/dist/overlay/popper/preview_popper.d.ts +7 -0
  79. package/dist/overlay/popper/preview_popper.d.ts.map +1 -0
  80. package/dist/overlay/popper/preview_popper.js +46 -0
  81. package/dist/overlay/popper/preview_popper.js.map +1 -0
  82. package/dist/overlay/tethered/element_tethered.d.ts +8 -0
  83. package/dist/overlay/tethered/element_tethered.d.ts.map +1 -0
  84. package/dist/overlay/tethered/element_tethered.js +33 -0
  85. package/dist/overlay/tethered/element_tethered.js.map +1 -0
  86. package/dist/overlay/tethered/hooks/calculate_position.d.ts +19 -0
  87. package/dist/overlay/tethered/hooks/calculate_position.d.ts.map +1 -0
  88. package/dist/overlay/tethered/hooks/calculate_position.js +43 -0
  89. package/dist/overlay/tethered/hooks/calculate_position.js.map +1 -0
  90. package/dist/overlay/tethered/hooks/useTether.d.ts +19 -0
  91. package/dist/overlay/tethered/hooks/useTether.d.ts.map +1 -0
  92. package/dist/overlay/tethered/hooks/useTether.js +61 -0
  93. package/dist/overlay/tethered/hooks/useTether.js.map +1 -0
  94. package/dist/overlay/tethered/tethered.d.ts +20 -0
  95. package/dist/overlay/tethered/tethered.d.ts.map +1 -0
  96. package/dist/overlay/tethered/tethered.js +59 -0
  97. package/dist/overlay/tethered/tethered.js.map +1 -0
  98. package/dist/overlay/tethered/types.d.ts +3 -0
  99. package/dist/overlay/tethered/types.d.ts.map +1 -0
  100. package/dist/overlay/tethered/types.js +2 -0
  101. package/dist/overlay/tethered/types.js.map +1 -0
  102. package/dist/popper.css +1 -1
  103. package/dist/scaffold.css +1 -0
  104. package/dist/stacks/box/box.js +1 -1
  105. package/dist/stacks/h_collapsible_box.js +1 -1
  106. package/dist/stacks/v_collapsible_box.js +1 -1
  107. package/dist/surfaces/card/card.d.ts +2 -2
  108. package/dist/surfaces/card/card.d.ts.map +1 -1
  109. package/dist/surfaces/card/card.js +7 -7
  110. package/dist/surfaces/card/card.js.map +1 -1
  111. package/dist/surfaces/index.d.ts +2 -0
  112. package/dist/surfaces/index.d.ts.map +1 -1
  113. package/dist/surfaces/index.js +22 -18
  114. package/dist/surfaces/index.js.map +1 -1
  115. package/dist/surfaces/modal/modal.d.ts +3 -3
  116. package/dist/surfaces/modal/modal.d.ts.map +1 -1
  117. package/dist/surfaces/modal/modal.js +14 -14
  118. package/dist/surfaces/modal/modal.js.map +1 -1
  119. package/dist/surfaces/panel/h_panel.js +23 -24
  120. package/dist/surfaces/panel/h_panel.js.map +1 -1
  121. package/dist/surfaces/panel/v_panel.d.ts +3 -7
  122. package/dist/surfaces/panel/v_panel.d.ts.map +1 -1
  123. package/dist/surfaces/panel/v_panel.js +12 -54
  124. package/dist/surfaces/panel/v_panel.js.map +1 -1
  125. package/dist/surfaces/pop_confirm/pop_confirm.d.ts +5 -0
  126. package/dist/surfaces/pop_confirm/pop_confirm.d.ts.map +1 -0
  127. package/dist/surfaces/pop_confirm/pop_confirm.js +37 -0
  128. package/dist/surfaces/pop_confirm/pop_confirm.js.map +1 -0
  129. package/dist/surfaces/popconfirm/pop_confirm.d.ts +5 -0
  130. package/dist/surfaces/popconfirm/pop_confirm.d.ts.map +1 -0
  131. package/dist/surfaces/popconfirm/pop_confirm.js +13 -0
  132. package/dist/surfaces/popconfirm/pop_confirm.js.map +1 -0
  133. package/dist/surfaces/popover/popover.d.ts +1 -1
  134. package/dist/surfaces/popover/popover.d.ts.map +1 -1
  135. package/dist/surfaces/popover/popover.js +1 -1
  136. package/dist/surfaces/popover/popover.js.map +1 -1
  137. package/dist/surfaces/tooltip/tooltip.d.ts +10 -0
  138. package/dist/surfaces/tooltip/tooltip.d.ts.map +1 -0
  139. package/dist/surfaces/tooltip/tooltip.js +38 -0
  140. package/dist/surfaces/tooltip/tooltip.js.map +1 -0
  141. package/dist/surfaces/window/window.d.ts +3 -3
  142. package/dist/surfaces/window/window.d.ts.map +1 -1
  143. package/dist/surfaces/window/window.js +16 -14
  144. package/dist/surfaces/window/window.js.map +1 -1
  145. package/dist/tethered.css +1 -0
  146. package/dist/themes/themes/ergo/ergo_theme.js +144 -205
  147. package/dist/themes/themes/ergo/ergo_theme.js.map +1 -1
  148. package/dist/tooltip.css +1 -1
  149. package/dist/utility_bar.css +1 -1
  150. package/dist/utils/click_away_listener.d.ts +1 -0
  151. package/dist/utils/click_away_listener.d.ts.map +1 -1
  152. package/dist/utils/click_away_listener.js +2 -1
  153. package/dist/utils/click_away_listener.js.map +1 -1
  154. package/dist/utils/index.d.ts +6 -5
  155. package/dist/utils/index.d.ts.map +1 -1
  156. package/dist/utils/index.js +26 -23
  157. package/dist/utils/index.js.map +1 -1
  158. package/dist/utils/mouse_leave_region.d.ts +8 -0
  159. package/dist/utils/mouse_leave_region.d.ts.map +1 -0
  160. package/dist/utils/mouse_leave_region.js +26 -0
  161. package/dist/utils/mouse_leave_region.js.map +1 -0
  162. package/dist/utils/types/dimensions.d.ts +11 -1
  163. package/dist/utils/types/dimensions.d.ts.map +1 -1
  164. package/package.json +3 -3
  165. package/src/inputs/color_input/color_input.tsx +1 -1
  166. package/src/inputs/date_picker/date_picker_input.tsx +1 -1
  167. package/src/inputs/date_picker/date_picker_year_input.tsx +1 -1
  168. package/src/inputs/suggestions/suggestion_list.tsx +1 -1
  169. package/src/layouts/index.ts +7 -5
  170. package/src/layouts/scaffold/scaffold.module.css +5 -0
  171. package/src/layouts/scaffold/scaffold.tsx +60 -0
  172. package/src/layouts/utility_bar/utility_bar.module.css +0 -3
  173. package/src/overlay/frame/frame.module.css +5 -0
  174. package/src/overlay/frame/frame.stories.tsx +1 -1
  175. package/src/overlay/frame/frame.tsx +19 -3
  176. package/src/overlay/index.ts +29 -2
  177. package/src/overlay/menu/menu.tsx +1 -1
  178. package/src/overlay/popper/__stories__/base_args.ts +75 -0
  179. package/src/overlay/popper/__stories__/context_popper.stories.tsx +77 -0
  180. package/src/overlay/popper/__stories__/element_popper.stories.tsx +80 -0
  181. package/src/overlay/popper/__stories__/preview_popper.stories.tsx +73 -0
  182. package/src/overlay/popper/base/base_popper.tsx +55 -0
  183. package/src/overlay/popper/base/dismissal_decorator.tsx +80 -0
  184. package/src/overlay/popper/context_popper.tsx +43 -0
  185. package/src/overlay/popper/element_popper.tsx +42 -0
  186. package/src/overlay/popper/hooks/use_context_trigger.ts +50 -0
  187. package/src/overlay/popper/hooks/use_hover_trigger.ts +24 -0
  188. package/src/overlay/popper/hooks/use_restore_focus.ts +16 -0
  189. package/src/overlay/popper/{popper.stories.tsx → legacy/popper.stories.tsx} +11 -5
  190. package/src/overlay/popper/{popper.tsx → legacy/popper.tsx} +3 -2
  191. package/src/overlay/popper/preview_popper.tsx +54 -0
  192. package/src/overlay/tethered/__stories__/element/element_tethered.stories.tsx +57 -0
  193. package/src/overlay/tethered/__stories__/element/element_tethered_stories.module.css +14 -0
  194. package/src/overlay/tethered/__stories__/shared/base_story_config.ts +52 -0
  195. package/src/overlay/tethered/__stories__/shared/components/sb_point.module.css +20 -0
  196. package/src/overlay/tethered/__stories__/shared/components/sb_point.tsx +34 -0
  197. package/src/overlay/tethered/__stories__/shared/components/sb_reference_points.tsx +54 -0
  198. package/src/overlay/tethered/__stories__/tethered/tethered.stories.tsx +90 -0
  199. package/src/overlay/tethered/__stories__/tethered/tethered_stories.module.css +25 -0
  200. package/src/overlay/tethered/element_tethered.tsx +62 -0
  201. package/src/overlay/tethered/hooks/calculate_position.ts +110 -0
  202. package/src/overlay/tethered/hooks/useTether.ts +85 -0
  203. package/src/overlay/tethered/tethered.module.css +8 -0
  204. package/src/overlay/tethered/tethered.tsx +72 -0
  205. package/src/overlay/tethered/types.ts +2 -0
  206. package/src/stacks/h_stack.stories.tsx +2 -2
  207. package/src/stacks/v_stack.stories.tsx +2 -2
  208. package/src/surfaces/card/card.stories.tsx +64 -0
  209. package/src/surfaces/card/card.tsx +4 -4
  210. package/src/surfaces/card/card_stories.module.css +13 -0
  211. package/src/surfaces/index.ts +2 -0
  212. package/src/surfaces/modal/__stories__/modal.stories.tsx +12 -1
  213. package/src/surfaces/modal/modal.module.css +2 -2
  214. package/src/surfaces/modal/modal.tsx +14 -12
  215. package/src/surfaces/panel/__stories__/panel.stories.tsx +1 -1
  216. package/src/surfaces/panel/v_panel.tsx +8 -53
  217. package/src/surfaces/pop_confirm/pop_confirm.stories.tsx +70 -0
  218. package/src/surfaces/pop_confirm/pop_confirm.tsx +30 -0
  219. package/src/surfaces/popconfirm/pop_confirm.tsx +18 -0
  220. package/src/surfaces/popover/popover.tsx +1 -1
  221. package/src/surfaces/tooltip/tooltip.stories.tsx +54 -0
  222. package/src/surfaces/tooltip/tooltip.tsx +59 -0
  223. package/src/surfaces/window/window.stories.tsx +15 -1
  224. package/src/surfaces/window/window.tsx +16 -12
  225. package/src/themes/themes/ergo/__stories__/components/tone_picker/sb_tone_picker.tsx +7 -9
  226. package/src/themes/themes/ergo/__stories__/material.stories.tsx +2 -6
  227. package/src/themes/themes/ergo/__stories__/sb_materials.module.css +29 -21
  228. package/src/themes/themes/ergo/ergo_theme.css +144 -205
  229. package/src/utils/click_away_listener.tsx +1 -1
  230. package/src/utils/index.ts +7 -5
  231. package/src/utils/mouse_leave_region.tsx +38 -0
  232. package/src/utils/types/dimensions.ts +13 -1
  233. package/tsconfig.json +3 -0
  234. package/dist/overlay/popper/popper.d.ts.map +0 -1
  235. package/dist/overlay/popper/popper.js.map +0 -1
  236. package/dist/overlay/tooltip/tooltip.d.ts +0 -7
  237. package/dist/overlay/tooltip/tooltip.d.ts.map +0 -1
  238. package/dist/overlay/tooltip/tooltip.js +0 -20
  239. package/dist/overlay/tooltip/tooltip.js.map +0 -1
  240. package/dist/panel.module-DwGKncon.js +0 -5
  241. package/dist/panel.module-DwGKncon.js.map +0 -1
  242. package/src/overlay/tooltip/tooltip.stories.tsx +0 -22
  243. package/src/overlay/tooltip/tooltip.tsx +0 -24
  244. /package/dist/{panel.css → h_panel.css} +0 -0
  245. /package/dist/overlay/popper/{popper.d.ts → legacy/popper.d.ts} +0 -0
  246. /package/src/overlay/popper/{popper.module.css → legacy/popper.module.css} +0 -0
  247. /package/src/{overlay → surfaces}/tooltip/tooltip.module.css +0 -0
@@ -0,0 +1,57 @@
1
+ import { useRef } from 'react';
2
+ import { HStack, VStack } from '../../../../stacks/index.js';
3
+ import { ElementTethered, type ElementTetheredProps } from '../../element_tethered.js';
4
+ import { tetheredArgTypes, tetheredArgs } from '../shared/base_story_config.js';
5
+
6
+ import styles from './element_tethered_stories.module.css';
7
+
8
+ export default {
9
+ title: 'Overlays/Element Tethered',
10
+ component: ElementTethered,
11
+ tags: ['autodocs'],
12
+ argTypes: tetheredArgTypes,
13
+ args: tetheredArgs,
14
+ };
15
+
16
+ type ElementTetheredStoryProps = Omit<ElementTetheredProps, 'anchorElement'>;
17
+
18
+ export const Default = (args: ElementTetheredStoryProps) => {
19
+ const anchorElement = useRef<HTMLDivElement>(null);
20
+
21
+ return (
22
+ <VStack height="100%" width="100%" hAlign="center" vAlign="center">
23
+ <VStack
24
+ className={styles.container}
25
+ hAlign="center"
26
+ vAlign="center"
27
+ height="500px"
28
+ width="500px"
29
+ >
30
+ <HStack
31
+ padding="16px"
32
+ width="auto"
33
+ height="auto"
34
+ ref={anchorElement}
35
+ className={styles.anchor}
36
+ hAlign="center"
37
+ vAlign="center"
38
+ >
39
+ Anchor Element
40
+ </HStack>
41
+
42
+ <ElementTethered anchorElement={anchorElement} {...args}>
43
+ <HStack
44
+ className={styles.tethered}
45
+ width="auto"
46
+ height="auto"
47
+ padding="16px"
48
+ hAlign="center"
49
+ vAlign="center"
50
+ >
51
+ Tethered to Anchor Element
52
+ </HStack>
53
+ </ElementTethered>
54
+ </VStack>
55
+ </VStack>
56
+ );
57
+ };
@@ -0,0 +1,14 @@
1
+ .container {
2
+ background-color: #f0eee7; /* eggshell */
3
+ }
4
+
5
+ .anchor {
6
+ background: white;
7
+ border-radius: 8px;
8
+ }
9
+
10
+ .tethered {
11
+ background: white;
12
+ border-radius: 8px;
13
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
14
+ }
@@ -0,0 +1,52 @@
1
+ import type { ArgTypes } from '@storybook/react-vite';
2
+ import type { TetheredProps } from '../../tethered.js';
3
+
4
+ type TetheredStoryArgs = Pick<
5
+ TetheredProps,
6
+ | 'verticalAnchor'
7
+ | 'horizontalAnchor'
8
+ | 'verticalOrigin'
9
+ | 'horizontalOrigin'
10
+ | 'verticalOffset'
11
+ | 'horizontalOffset'
12
+ >;
13
+
14
+ export const tetheredArgTypes: ArgTypes<TetheredStoryArgs> = {
15
+ verticalAnchor: {
16
+ options: ['top', 'center', 'bottom'],
17
+ control: { type: 'radio' },
18
+ description: 'The anchor to position the popper vertically.',
19
+ },
20
+ horizontalAnchor: {
21
+ options: ['start', 'center', 'end'],
22
+ control: { type: 'radio' },
23
+ description: 'The anchor to position the popper horizontally.',
24
+ },
25
+ verticalOrigin: {
26
+ options: ['top', 'center', 'bottom'],
27
+ control: { type: 'radio' },
28
+ description: 'The origin to position the popper vertically.',
29
+ },
30
+ horizontalOrigin: {
31
+ options: ['start', 'center', 'end'],
32
+ control: { type: 'radio' },
33
+ description: 'The origin to position the popper horizontally.',
34
+ },
35
+ verticalOffset: {
36
+ control: { type: 'number' },
37
+ description: 'The offset to position the popper vertically.',
38
+ },
39
+ horizontalOffset: {
40
+ control: { type: 'number' },
41
+ description: 'The offset to position the popper horizontally.',
42
+ },
43
+ };
44
+
45
+ export const tetheredArgs: TetheredStoryArgs = {
46
+ verticalAnchor: 'top',
47
+ horizontalAnchor: 'center',
48
+ verticalOrigin: 'bottom',
49
+ horizontalOrigin: 'center',
50
+ verticalOffset: 0,
51
+ horizontalOffset: 0,
52
+ };
@@ -0,0 +1,20 @@
1
+ .point {
2
+ position: absolute;
3
+ left: var(--point-x);
4
+ top: var(--point-y);
5
+ border-radius: 50%;
6
+ background: black;
7
+ pointer-events: none;
8
+ opacity: 0.5;
9
+ translate: -50% -50%;
10
+ width: var(--point-size);
11
+ height: var(--point-size);
12
+ }
13
+
14
+ .point[data-selected="true"] {
15
+ width: calc(var(--point-size) * 1.5);
16
+ height: calc(var(--point-size) * 1.5);
17
+ opacity: 1;
18
+ background: var(--accent-color);
19
+ border: 1px solid white;
20
+ }
@@ -0,0 +1,34 @@
1
+ import { Box, type BoxProps } from '../../../../../stacks/index.js';
2
+ import type { Position } from '../../../../../utils/index.js';
3
+
4
+ import styles from './sb_point.module.css';
5
+
6
+ export interface SB_TetheredPointProps extends BoxProps {
7
+ point: Position;
8
+ isSelected: boolean;
9
+ size?: number;
10
+ }
11
+
12
+ export const SB_TetheredPoint: React.FC<SB_TetheredPointProps> = ({
13
+ point,
14
+ isSelected,
15
+ style,
16
+ size = 8,
17
+ ...rest
18
+ }) => {
19
+ return (
20
+ <Box
21
+ className={styles.point}
22
+ data-selected={isSelected}
23
+ style={
24
+ {
25
+ ...style,
26
+ '--point-size': `${size}px`,
27
+ '--point-x': `${point.x}px`,
28
+ '--point-y': `${point.y}px`,
29
+ } as React.CSSProperties
30
+ }
31
+ {...rest}
32
+ />
33
+ );
34
+ };
@@ -0,0 +1,54 @@
1
+ import type { Dimensions } from '../../../../../utils/index.js';
2
+ import type { VerticalTether, HorizontalTether } from '../../../types.js';
3
+ import { SB_TetheredPoint } from './sb_point.js';
4
+
5
+ type PointValue = `${VerticalTether}-${HorizontalTether}`;
6
+
7
+ type SBReferencePoint = {
8
+ x: number;
9
+ y: number;
10
+ value: PointValue;
11
+ };
12
+
13
+ const mid = (value: number) => value / 2;
14
+
15
+ const getPoints = (dimensions: Dimensions): SBReferencePoint[] => {
16
+ const mWidth = mid(dimensions.width);
17
+ const mHeight = mid(dimensions.height);
18
+ const { width, height } = dimensions;
19
+ return [
20
+ { x: 0, y: 0, value: 'top-start' },
21
+ { x: mWidth, y: 0, value: 'top-center' },
22
+ { x: width, y: 0, value: 'top-end' },
23
+ { x: 0, y: mHeight, value: 'center-start' },
24
+ { x: mWidth, y: mHeight, value: 'center-center' },
25
+ { x: width, y: mHeight, value: 'center-end' },
26
+ { x: 0, y: height, value: 'bottom-start' },
27
+ { x: mWidth, y: height, value: 'bottom-center' },
28
+ { x: width, y: height, value: 'bottom-end' },
29
+ ];
30
+ };
31
+
32
+ const getAnchorValue = (
33
+ verticalAnchor: VerticalTether,
34
+ horizontalAnchor: HorizontalTether
35
+ ): PointValue => {
36
+ return `${verticalAnchor}-${horizontalAnchor}`;
37
+ };
38
+
39
+ export const SBReferencePoints: React.FC<{
40
+ dimensions: Dimensions;
41
+ vertical: VerticalTether;
42
+ horizontal: HorizontalTether;
43
+ }> = ({ dimensions, vertical, horizontal }) => {
44
+ const anchorPoints = getPoints(dimensions);
45
+ return (
46
+ <>
47
+ {anchorPoints.map((point, index) => {
48
+ const value = getAnchorValue(vertical, horizontal);
49
+ const isSelected = value === point.value;
50
+ return <SB_TetheredPoint key={index} point={point} isSelected={isSelected} />;
51
+ })}
52
+ </>
53
+ );
54
+ };
@@ -0,0 +1,90 @@
1
+ import { Box, VStack } from '../../../../stacks/index.js';
2
+ import type { Rectangle } from '../../../../utils/index.js';
3
+ import { Portal } from '../../../portal/portal.js';
4
+ import { Tethered, type TetheredProps } from '../../tethered.js';
5
+ import { tetheredArgTypes, tetheredArgs } from '../shared/base_story_config.js';
6
+ import { SBReferencePoints } from '../shared/components/sb_reference_points.js';
7
+
8
+ import styles from './tethered_stories.module.css';
9
+
10
+ export default {
11
+ title: 'Overlays/Tethered',
12
+ component: Tethered,
13
+ tags: ['autodocs'],
14
+ argTypes: tetheredArgTypes,
15
+ args: tetheredArgs,
16
+ };
17
+
18
+ type TetheredStoryProps = Omit<TetheredProps, 'anchor'>;
19
+
20
+ export const Default = (args: TetheredStoryProps) => {
21
+ const anchorRect: Rectangle = {
22
+ dimensions: {
23
+ width: 150,
24
+ height: 75,
25
+ },
26
+ position: {
27
+ x: 150,
28
+ y: 200,
29
+ },
30
+ };
31
+
32
+ const tetherRect = {
33
+ dimensions: {
34
+ width: 200,
35
+ height: 50,
36
+ },
37
+ position: {
38
+ x: 100,
39
+ y: 100,
40
+ },
41
+ };
42
+
43
+ return (
44
+ <VStack height="100%" width="100%" hAlign="center" vAlign="center">
45
+ <VStack
46
+ className={styles.container}
47
+ hAlign="center"
48
+ vAlign="center"
49
+ height="500px"
50
+ width="500px"
51
+ >
52
+ <Portal>
53
+ <Box
54
+ className={styles.anchor}
55
+ height={anchorRect.dimensions.height}
56
+ width={anchorRect.dimensions.width}
57
+ style={
58
+ {
59
+ '--anchor-top': `${anchorRect.position.y}px`,
60
+ '--anchor-left': `${anchorRect.position.x}px`,
61
+ } as React.CSSProperties
62
+ }
63
+ >
64
+ Anchor Rectangle
65
+ <SBReferencePoints
66
+ dimensions={anchorRect.dimensions}
67
+ vertical={args.verticalAnchor || 'top'}
68
+ horizontal={args.horizontalAnchor || 'start'}
69
+ />
70
+ </Box>
71
+ </Portal>
72
+
73
+ <Tethered anchor={anchorRect} {...args}>
74
+ <Box
75
+ className={styles.tether}
76
+ height={tetherRect.dimensions.height}
77
+ width={tetherRect.dimensions.width}
78
+ >
79
+ Tether to Anchor Rectangle
80
+ <SBReferencePoints
81
+ dimensions={tetherRect.dimensions}
82
+ vertical={args.verticalOrigin || 'top'}
83
+ horizontal={args.horizontalOrigin || 'start'}
84
+ />
85
+ </Box>
86
+ </Tethered>
87
+ </VStack>
88
+ </VStack>
89
+ );
90
+ };
@@ -0,0 +1,25 @@
1
+ .container {
2
+ background-color: grey;
3
+ position: relative;
4
+ }
5
+
6
+ .anchor {
7
+ background-color: blue;
8
+ display: flex;
9
+ flex-direction: column;
10
+ align-items: center;
11
+ justify-content: center;
12
+ color: white;
13
+ position: absolute;
14
+ top: var(--anchor-top);
15
+ left: var(--anchor-left);
16
+ }
17
+
18
+ .tether {
19
+ background-color: green;
20
+ display: flex;
21
+ flex-direction: column;
22
+ align-items: center;
23
+ justify-content: center;
24
+ color: white;
25
+ }
@@ -0,0 +1,62 @@
1
+ import { forwardRef, useLayoutEffect, useState, type PropsWithChildren } from 'react';
2
+ import { type Rectangle } from '../../utils/index.js';
3
+ import { Tethered, type TetheredProps } from './tethered.js';
4
+
5
+ export interface ElementTetheredProps extends Omit<TetheredProps, 'anchor'> {
6
+ anchorElement: React.RefObject<HTMLElement>;
7
+ }
8
+
9
+ function getElementRect(element: HTMLElement | null): Rectangle | null {
10
+ if (!element) return null;
11
+
12
+ const clientRect = element.getBoundingClientRect();
13
+
14
+ return {
15
+ position: { x: clientRect.left, y: clientRect.top },
16
+ dimensions: {
17
+ width: clientRect.width,
18
+ height: clientRect.height,
19
+ },
20
+ };
21
+ }
22
+
23
+ export const ElementTethered = forwardRef<
24
+ HTMLElement,
25
+ PropsWithChildren<ElementTetheredProps>
26
+ >(function ElementTether({ anchorElement, children, ...rest }, ref) {
27
+ const [rectangle, setRectangle] = useState<Rectangle | null>(null);
28
+
29
+ useLayoutEffect(() => {
30
+ const element = anchorElement.current;
31
+ if (!element) return;
32
+
33
+ const update = () => {
34
+ setRectangle(getElementRect(element));
35
+ };
36
+
37
+ update();
38
+
39
+ // Track size/position changes of the anchor element via ResizeObserver
40
+ const resizeObserver = new ResizeObserver(update);
41
+ resizeObserver.observe(element);
42
+
43
+ // Track scroll-induced position changes
44
+ // TODO: might need to bypass react state here (setRectangle on scroll event) - to an OV or setting css variables/attributes directly
45
+ // This is a slight lag - not a huge deal, but could be improved.
46
+ window.addEventListener('scroll', update, true);
47
+ // Track window resize-induced position changes
48
+ window.addEventListener('resize', update);
49
+
50
+ return () => {
51
+ resizeObserver.disconnect();
52
+ window.removeEventListener('scroll', update, true);
53
+ window.removeEventListener('resize', update);
54
+ };
55
+ }, [anchorElement]);
56
+
57
+ return (
58
+ <Tethered ref={ref} anchor={rectangle} {...rest}>
59
+ {children}
60
+ </Tethered>
61
+ );
62
+ });
@@ -0,0 +1,110 @@
1
+ import type { HorizontalTether, VerticalTether } from '../types.js';
2
+ import type { Rectangle } from '../../../utils/index.js';
3
+ import type { Dimensions } from '../../../utils/index.js';
4
+
5
+ export interface CalculateTetheredPositionParams {
6
+ anchor: Rectangle;
7
+ tether: Rectangle;
8
+ direction: 'ltr' | 'rtl';
9
+ verticalAnchor: VerticalTether;
10
+ verticalOrigin: VerticalTether;
11
+ horizontalAnchor: HorizontalTether;
12
+ horizontalOrigin: HorizontalTether;
13
+ verticalOffset: number;
14
+ horizontalOffset: number;
15
+ viewport: Dimensions;
16
+ }
17
+
18
+ export const calculateTetheredPosition = ({
19
+ anchor,
20
+ tether,
21
+ direction,
22
+ verticalAnchor,
23
+ verticalOrigin,
24
+ horizontalAnchor,
25
+ horizontalOrigin,
26
+ verticalOffset,
27
+ horizontalOffset,
28
+ viewport,
29
+ }: CalculateTetheredPositionParams) => {
30
+ const isRtl = direction === 'rtl';
31
+
32
+ let top = anchor.position.y;
33
+ let left = anchor.position.x;
34
+
35
+ // Calculate vertical position
36
+ switch (verticalAnchor) {
37
+ case 'top':
38
+ top += verticalOffset;
39
+ break;
40
+ case 'center':
41
+ top += anchor.dimensions.height / 2;
42
+ break;
43
+ case 'bottom':
44
+ top += anchor.dimensions.height - verticalOffset;
45
+ break;
46
+ }
47
+
48
+ switch (verticalOrigin) {
49
+ case 'top':
50
+ break;
51
+ case 'center':
52
+ top -= tether.dimensions.height / 2;
53
+ break;
54
+ case 'bottom':
55
+ top -= tether.dimensions.height;
56
+ break;
57
+ }
58
+
59
+ // Calculate horizontal position with direction sensitivity
60
+ if (horizontalAnchor === 'start') {
61
+ left += isRtl ? anchor.dimensions.width + horizontalOffset : horizontalOffset;
62
+ } else if (horizontalAnchor === 'center') {
63
+ left += anchor.dimensions.width / 2;
64
+ } else if (horizontalAnchor === 'end') {
65
+ left += isRtl ? -horizontalOffset : anchor.dimensions.width + horizontalOffset;
66
+ }
67
+
68
+ // Adjust the origin based on RTL direction
69
+ let adjustedHorizontalOrigin = horizontalOrigin;
70
+ if (isRtl) {
71
+ if (horizontalOrigin === 'start') {
72
+ adjustedHorizontalOrigin = 'end';
73
+ } else if (horizontalOrigin === 'end') {
74
+ adjustedHorizontalOrigin = 'start';
75
+ }
76
+ }
77
+
78
+ // Apply adjusted origin to the position calculation
79
+ if (adjustedHorizontalOrigin === 'start') {
80
+ // No adjustment needed
81
+ } else if (adjustedHorizontalOrigin === 'center') {
82
+ left -= tether.dimensions.width / 2;
83
+ } else if (adjustedHorizontalOrigin === 'end') {
84
+ left -= tether.dimensions.width;
85
+ }
86
+
87
+ // Ensure the popover stays within the viewport
88
+ // Prevent overflow to the right
89
+ if (left + tether.dimensions.width > viewport.width) {
90
+ left = viewport.width - tether.dimensions.width;
91
+ }
92
+
93
+ // Prevent overflow to the left
94
+ if (left < 0) {
95
+ left = 0;
96
+ }
97
+
98
+ // Prevent overflow to the bottom
99
+ // FIXME: doesn't account for padding.
100
+ if (top + tether.dimensions.height > viewport.height) {
101
+ top = viewport.height - tether.dimensions.height;
102
+ }
103
+
104
+ // Prevent overflow to the top
105
+ if (top < 0) {
106
+ top = 0;
107
+ }
108
+
109
+ return { top, left };
110
+ };
@@ -0,0 +1,85 @@
1
+ import { useCallback, useLayoutEffect, useRef, useState } from 'react';
2
+ import type { HorizontalTether, VerticalTether } from '../types.js';
3
+ import { type Rectangle } from '../../../utils/index.js';
4
+ import { calculateTetheredPosition } from './calculate_position.js';
5
+
6
+ export interface UseTetherParams {
7
+ anchor: Rectangle | null;
8
+ verticalAnchor?: VerticalTether;
9
+ verticalOrigin?: VerticalTether;
10
+ horizontalOrigin?: HorizontalTether;
11
+ horizontalAnchor?: HorizontalTether;
12
+ verticalOffset?: number;
13
+ horizontalOffset?: number;
14
+ }
15
+
16
+ export function useTether({
17
+ anchor,
18
+ verticalAnchor = 'bottom',
19
+ verticalOrigin = 'top',
20
+ horizontalAnchor = 'start',
21
+ horizontalOrigin = 'start',
22
+ verticalOffset = 0,
23
+ horizontalOffset = 0,
24
+ }: UseTetherParams) {
25
+ const [position, setPosition] = useState({ top: 0, left: 0 });
26
+ const tetherRef = useRef<HTMLDivElement>(null);
27
+
28
+ const getPosition = useCallback(() => {
29
+ if (!anchor || !tetherRef.current) return;
30
+
31
+ const tether = tetherRef.current.getBoundingClientRect();
32
+ const computedStyle = getComputedStyle(tetherRef.current);
33
+
34
+ return calculateTetheredPosition({
35
+ anchor: anchor,
36
+ tether: {
37
+ dimensions: {
38
+ width: tether.width,
39
+ height: tether.height,
40
+ },
41
+ position: {
42
+ x: tether.left,
43
+ y: tether.top,
44
+ },
45
+ },
46
+ direction: computedStyle.direction as 'ltr' | 'rtl',
47
+ verticalAnchor: verticalAnchor,
48
+ verticalOrigin: verticalOrigin,
49
+ horizontalAnchor: horizontalAnchor,
50
+ horizontalOrigin: horizontalOrigin,
51
+ verticalOffset: verticalOffset,
52
+ horizontalOffset: horizontalOffset,
53
+ viewport: {
54
+ width: window.innerWidth,
55
+ height: window.innerHeight,
56
+ },
57
+ });
58
+ }, [
59
+ anchor,
60
+ verticalAnchor,
61
+ verticalOrigin,
62
+ horizontalAnchor,
63
+ horizontalOrigin,
64
+ verticalOffset,
65
+ horizontalOffset,
66
+ ]);
67
+
68
+ // Update the position when the window is resized
69
+ useLayoutEffect(() => {
70
+ const update = () => {
71
+ const newPosition = getPosition();
72
+ if (!newPosition) return;
73
+ if (position.top !== newPosition.top || position.left !== newPosition.left) {
74
+ setPosition(newPosition);
75
+ }
76
+ };
77
+ update();
78
+ window.addEventListener('resize', update);
79
+ return () => {
80
+ window.removeEventListener('resize', update);
81
+ };
82
+ });
83
+
84
+ return { position, tetherRef };
85
+ }
@@ -0,0 +1,8 @@
1
+ .tethered {
2
+ display: inline-block;
3
+ position: absolute;
4
+ width: auto;
5
+ height: auto;
6
+ left: var(--tethered-left, 0);
7
+ top: var(--tethered-top, 0);
8
+ }
@@ -0,0 +1,72 @@
1
+ import { forwardRef, type PropsWithChildren } from 'react';
2
+ import type { HorizontalTether, VerticalTether } from './types.js';
3
+ import { ZStack, type ZStackProps } from '../../stacks/index.js';
4
+ import { useForkRef, type Rectangle } from '../../utils/index.js';
5
+ import { useTether } from './hooks/useTether.js';
6
+ import { clsx } from 'clsx';
7
+ import { Portal } from '../portal/portal.js';
8
+
9
+ // Styles
10
+ import styles from './tethered.module.css';
11
+
12
+ export interface BaseTetheredOwnProps {
13
+ verticalAnchor?: VerticalTether;
14
+ verticalOrigin?: VerticalTether;
15
+ horizontalOrigin?: HorizontalTether;
16
+ horizontalAnchor?: HorizontalTether;
17
+ verticalOffset?: number;
18
+ horizontalOffset?: number;
19
+ }
20
+
21
+ export interface TetheredOwnProp extends BaseTetheredOwnProps {
22
+ anchor: Rectangle | null;
23
+ }
24
+ export interface TetheredProps extends TetheredOwnProp, ZStackProps {}
25
+
26
+ export const Tethered = forwardRef<HTMLElement, PropsWithChildren<TetheredProps>>(
27
+ function Tethered(
28
+ {
29
+ anchor,
30
+ verticalAnchor = 'bottom',
31
+ verticalOrigin = 'top',
32
+ horizontalAnchor = 'start',
33
+ horizontalOrigin = 'start',
34
+ verticalOffset = 0,
35
+ horizontalOffset = 0,
36
+ children,
37
+ style,
38
+ className,
39
+ ...rest
40
+ },
41
+ ref
42
+ ) {
43
+ const { position, tetherRef } = useTether({
44
+ anchor,
45
+ verticalAnchor,
46
+ verticalOrigin,
47
+ horizontalAnchor,
48
+ horizontalOrigin,
49
+ verticalOffset,
50
+ horizontalOffset,
51
+ });
52
+ const forkedRef = useForkRef(ref, tetherRef);
53
+
54
+ const cssVariables = {
55
+ '--tethered-top': `${position.top}px`,
56
+ '--tethered-left': `${position.left}px`,
57
+ };
58
+
59
+ return (
60
+ <Portal>
61
+ <ZStack
62
+ ref={forkedRef}
63
+ className={clsx(styles.tethered, className)}
64
+ style={{ position: 'absolute', ...cssVariables, ...style }}
65
+ {...rest}
66
+ >
67
+ {children}
68
+ </ZStack>
69
+ </Portal>
70
+ );
71
+ }
72
+ );
@@ -0,0 +1,2 @@
1
+ export type VerticalTether = 'top' | 'center' | 'bottom';
2
+ export type HorizontalTether = 'start' | 'center' | 'end';