Unverified Commit b395d8c2 authored by Guilherme Gazzo's avatar Guilherme Gazzo Committed by GitHub
Browse files

feat: usePosition (#259)

parent f586722b
......@@ -64,18 +64,20 @@ yarn add @rocket.chat/fuselage-hooks
- [Parameters](#parameters-9)
- [useMutableCallback](#usemutablecallback)
- [Parameters](#parameters-10)
- [useResizeObserver](#useresizeobserver)
- [usePosition](#useposition)
- [Parameters](#parameters-11)
- [useSafely](#usesafely)
- [useResizeObserver](#useresizeobserver)
- [Parameters](#parameters-12)
- [useStableArray](#usestablearray)
- [useSafely](#usesafely)
- [Parameters](#parameters-13)
- [useLocalStorage](#uselocalstorage)
- [useStableArray](#usestablearray)
- [Parameters](#parameters-14)
- [useSessionStorage](#usesessionstorage)
- [useLocalStorage](#uselocalstorage)
- [Parameters](#parameters-15)
- [useToggle](#usetoggle)
- [useSessionStorage](#usesessionstorage)
- [Parameters](#parameters-16)
- [useToggle](#usetoggle)
- [Parameters](#parameters-17)
- [useUniqueId](#useuniqueid)
### useAutoFocus
......@@ -211,6 +213,18 @@ Hook to create a stable callback from a mutable one.
Returns **function (...args: P): T** a stable callback
### usePosition
Hook to deal with sessionStorage
#### Parameters
- `reference` **[Element](https://developer.mozilla.org/docs/Web/API/Element)** the anchor
- `targetEl` **[Element](https://developer.mozilla.org/docs/Web/API/Element)** the element to be positioned
- `options` **PostionOptions** options to position
Returns **(PositionStyle | null)** a state and a setter function
### useResizeObserver
Hook to track dimension changes in a DOM element using the ResizeObserver API.
......
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [@rocket.chat/fuselage-hooks](./fuselage-hooks.md) &gt; [getPositionStyle](./fuselage-hooks.getpositionstyle.md)
## getPositionStyle variable
<b>Signature:</b>
```typescript
getPositionStyle: ({ placement, container, targetBoundaries, variantStore, target }: {
placement: Placements;
target: DOMRect;
container: DOMRect;
targetBoundaries: Boundaries;
variantStore: VariantBoundaries;
}) => PositionStyle | null
```
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [@rocket.chat/fuselage-hooks](./fuselage-hooks.md) &gt; [getTargetBoundaries](./fuselage-hooks.gettargetboundaries.md)
## getTargetBoundaries variable
<b>Signature:</b>
```typescript
getTargetBoundaries: ({ referenceBox, target, margin }: {
referenceBox: DOMRect;
target: DOMRect;
margin?: number;
}) => Boundaries
```
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [@rocket.chat/fuselage-hooks](./fuselage-hooks.md) &gt; [getVariantBoundaries](./fuselage-hooks.getvariantboundaries.md)
## getVariantBoundaries variable
<b>Signature:</b>
```typescript
getVariantBoundaries: ({ referenceBox, target }: {
referenceBox: DOMRect;
target: DOMRect;
}) => VariantBoundaries
```
......@@ -19,6 +19,9 @@
| Variable | Description |
| --- | --- |
| [getPositionStyle](./fuselage-hooks.getpositionstyle.md) | |
| [getTargetBoundaries](./fuselage-hooks.gettargetboundaries.md) | |
| [getVariantBoundaries](./fuselage-hooks.getvariantboundaries.md) | |
| [useAutoFocus](./fuselage-hooks.useautofocus.md) | Hook to automatically request focus for an DOM element. |
| [useBreakpoints](./fuselage-hooks.usebreakpoints.md) | Hook to catch which responsive design' breakpoints are active. |
| [useDebouncedCallback](./fuselage-hooks.usedebouncedcallback.md) | Hook to memoize a debounced version of a callback. |
......@@ -30,6 +33,7 @@
| [useMediaQuery](./fuselage-hooks.usemediaquery.md) | Hook to listen to a media query. |
| [useMergedRefs](./fuselage-hooks.usemergedrefs.md) | Hook to merge refs and callbacks refs into a single callback ref. Useful when your component need a internal ref while receiving a forwared ref. |
| [useMutableCallback](./fuselage-hooks.usemutablecallback.md) | Hook to create a stable callback from a mutable one. |
| [usePosition](./fuselage-hooks.useposition.md) | Hook to deal with sessionStorage |
| [useResizeObserver](./fuselage-hooks.useresizeobserver.md) | Hook to track dimension changes in a DOM element using the ResizeObserver API. |
| [useSafely](./fuselage-hooks.usesafely.md) | Hook that wraps pairs of state and dispatcher to provide a new dispatcher which can be safe and asynchronically called even after the component unmounted. |
| [useSessionStorage](./fuselage-hooks.usesessionstorage.md) | Hook to deal with sessionStorage |
......@@ -37,3 +41,12 @@
| [useToggle](./fuselage-hooks.usetoggle.md) | Hook to create a toggleable boolean state. |
| [useUniqueId](./fuselage-hooks.useuniqueid.md) | Hook to keep a unique ID string. |
## Type Aliases
| Type Alias | Description |
| --- | --- |
| [Placements](./fuselage-hooks.placements.md) | |
| [PositionFlipOrder](./fuselage-hooks.positionfliporder.md) | |
| [Positions](./fuselage-hooks.positions.md) | |
| [PostionOptions](./fuselage-hooks.postionoptions.md) | |
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [@rocket.chat/fuselage-hooks](./fuselage-hooks.md) &gt; [Placements](./fuselage-hooks.placements.md)
## Placements type
<b>Signature:</b>
```typescript
export declare type Placements = 'top-start' | 'top-middle' | 'top-end' | 'bottom-start' | 'bottom-middle' | 'bottom-end' | 'left-start' | 'left-middle' | 'left-end' | 'right-start' | 'right-middle' | 'right-end' | Positions;
```
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [@rocket.chat/fuselage-hooks](./fuselage-hooks.md) &gt; [PositionFlipOrder](./fuselage-hooks.positionfliporder.md)
## PositionFlipOrder type
<b>Signature:</b>
```typescript
export declare type PositionFlipOrder = {
top: string;
right: string;
bottom: string;
left: string;
};
```
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [@rocket.chat/fuselage-hooks](./fuselage-hooks.md) &gt; [Positions](./fuselage-hooks.positions.md)
## Positions type
<b>Signature:</b>
```typescript
export declare type Positions = 'top' | 'left' | 'bottom' | 'right';
```
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [@rocket.chat/fuselage-hooks](./fuselage-hooks.md) &gt; [PostionOptions](./fuselage-hooks.postionoptions.md)
## PostionOptions type
<b>Signature:</b>
```typescript
export declare type PostionOptions = {
margin?: number;
container?: Element;
placement?: Placements;
watch?: boolean;
};
```
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [@rocket.chat/fuselage-hooks](./fuselage-hooks.md) &gt; [usePosition](./fuselage-hooks.useposition.md)
## usePosition variable
Hook to deal with sessionStorage
<b>Signature:</b>
```typescript
usePosition: (reference: Element, targetEl: Element, options: PostionOptions) => PositionStyle | null
```
......@@ -11,6 +11,7 @@ export * from './useMediaQueries';
export * from './useMediaQuery';
export * from './useMergedRefs';
export * from './useMutableCallback';
export * from './usePosition';
export * from './useResizeObserver';
export * from './useSafely';
export * from './useStableArray';
......
import { getPositionStyle, getTargetBoundaries, getVariantBoundaries } from './usePosition';
// TODO: add tests targeting the hook itself
const container = {
bottom: 1000,
height: 1000,
left: 0,
right: 1024,
top: 0,
width: 1024,
x: 0,
y: 0,
} as DOMRect;
const referenceBox = {
bottom: 300,
height: 100,
left: 0,
right: 100,
top: 200,
width: 100,
x: 0,
y: 200,
} as DOMRect;
const target = {
bottom: 50,
height: 50,
left: 0,
right: 50,
top: 0,
width: 50,
x: 0,
y: 0,
} as DOMRect;
describe('usePosition hook', () => {
describe('getTargetBoundaries', () => {
it('...', () => {
const targetBoundaries = getTargetBoundaries({ referenceBox, target });
expect(targetBoundaries.t).toEqual(150);
expect(targetBoundaries.b).toEqual(300);
expect(targetBoundaries.r).toEqual(100);
expect(targetBoundaries.l).toEqual(-50);
});
});
describe('getPositionStyle function', () => {
it('returns a style for placement bottom-start', () => {
const targetBoundaries = getTargetBoundaries({ referenceBox, target });
const variantStore = getVariantBoundaries({ referenceBox, target });
const style = getPositionStyle({ placement: 'bottom-start', container, targetBoundaries, variantStore, target });
expect(style.left).toEqual('0px');
expect(style.top).toEqual('300px');
});
it('returns a style for placement bottom-start if the element height does not fit', () => {
const targetBoundaries = getTargetBoundaries({ referenceBox, target });
const variantStore = getVariantBoundaries({ referenceBox, target });
const style = getPositionStyle({ placement: 'bottom-start',
container: {
...container,
bottom: 300,
height: 300,
},
targetBoundaries,
variantStore,
target });
expect(style.left).toEqual('0px');
expect(style.top).toEqual('150px');
});
it('returns a style for placement bottom-middle', () => {
const targetBoundaries = getTargetBoundaries({ referenceBox, target });
const variantStore = getVariantBoundaries({ referenceBox, target });
const style = getPositionStyle({ placement: 'bottom-middle', container, targetBoundaries, variantStore, target });
expect(style.left).toEqual('25px');
expect(style.top).toEqual('300px');
});
it('returns a style for placement bottom-middle if the element height does not fit', () => {
const targetBoundaries = getTargetBoundaries({ referenceBox, target });
const variantStore = getVariantBoundaries({ referenceBox, target });
const style = getPositionStyle({ placement: 'bottom-middle',
container: {
...container,
bottom: 300,
height: 300,
},
targetBoundaries,
variantStore,
target });
expect(style.left).toEqual('25px');
expect(style.top).toEqual('150px');
});
it('returns a style for placement bottom-end', () => {
const targetBoundaries = getTargetBoundaries({ referenceBox, target });
const variantStore = getVariantBoundaries({ referenceBox, target });
const style = getPositionStyle({ placement: 'bottom-end', container, targetBoundaries, variantStore, target });
expect(style.left).toEqual('50px');
expect(style.top).toEqual('300px');
});
it('returns a style for placement bottom-end if the element height does not fit', () => {
const targetBoundaries = getTargetBoundaries({ referenceBox, target });
const variantStore = getVariantBoundaries({ referenceBox, target });
const style = getPositionStyle({ placement: 'bottom-end',
container: {
...container,
bottom: 300,
height: 300,
},
targetBoundaries,
variantStore,
target });
expect(style.left).toEqual('50px');
expect(style.top).toEqual('150px');
});
it('returns a style for placement top-start', () => {
const targetBoundaries = getTargetBoundaries({ referenceBox, target });
const variantStore = getVariantBoundaries({ referenceBox, target });
const style = getPositionStyle({ placement: 'top-start', container, targetBoundaries, variantStore, target });
expect(style.left).toEqual('0px');
expect(style.top).toEqual('150px');
});
it('returns a style for placement top-start if the element height does not fit', () => {
const box = { ...referenceBox, top: 10, y: 10, bottom: 110 };
const targetBoundaries = getTargetBoundaries({ referenceBox: box, target });
const variantStore = getVariantBoundaries({ referenceBox: box, target });
const style = getPositionStyle({ placement: 'top-start', container, targetBoundaries, variantStore, target });
expect(style.left).toEqual('0px');
expect(style.top).toEqual('110px');
});
it('returns a style for placement top-middle', () => {
const targetBoundaries = getTargetBoundaries({ referenceBox, target });
const variantStore = getVariantBoundaries({ referenceBox, target });
const style = getPositionStyle({ placement: 'top-middle', container, targetBoundaries, variantStore, target });
expect(style.left).toEqual('25px');
expect(style.top).toEqual('150px');
});
it('returns a style for placement top-middle if the element height does not fit', () => {
const box = { ...referenceBox, top: 10, y: 10, bottom: 110 };
const targetBoundaries = getTargetBoundaries({ referenceBox: box, target });
const variantStore = getVariantBoundaries({ referenceBox: box, target });
const style = getPositionStyle({ placement: 'top-middle', container, targetBoundaries, variantStore, target });
expect(style.left).toEqual('25px');
expect(style.top).toEqual('110px');
});
it('returns a style for placement top-end', () => {
const targetBoundaries = getTargetBoundaries({ referenceBox, target });
const variantStore = getVariantBoundaries({ referenceBox, target });
const style = getPositionStyle({ placement: 'top-end', container, targetBoundaries, variantStore, target });
expect(style.left).toEqual('50px');
expect(style.top).toEqual('150px');
});
it('returns a style for placement top-end if the element height does not fit', () => {
const box = { ...referenceBox, top: 10, y: 10, bottom: 110 };
const targetBoundaries = getTargetBoundaries({ referenceBox: box, target });
const variantStore = getVariantBoundaries({ referenceBox: box, target });
const style = getPositionStyle({ placement: 'top-end', container, targetBoundaries, variantStore, target });
expect(style.left).toEqual('50px');
expect(style.top).toEqual('110px');
});
});
});
import { useEffect, RefObject, useCallback, useRef } from 'react';
import { useDebouncedState } from './useDebouncedState';
export type PositionOptions = {
margin?: number;
container?: Element;
placement?: Placements;
watch?: boolean;
};
export type PositionFlipOrder = {
top: string;
right: string;
bottom: string;
left: string;
};
type Boundaries = {
t: number,
b: number,
r: number,
l: number,
}
type VariantBoundaries = {
vm: number,
vs: number,
ve: number,
hs: number,
he: number,
hm: number,
}
type PositionStyle = {
top?: string,
left?: string,
position?: 'fixed',
zIndex?: '9999',
transition?: 'none !important',
opacity?: 0 | 1,
}
export type Positions = 'top' | 'left' | 'bottom' | 'right';
export type Placements =
'top-start' | 'top-middle' | 'top-end' |
'bottom-start' | 'bottom-middle' | 'bottom-end' |
'left-start' | 'left-middle' | 'left-end' |
'right-start' | 'right-middle' | 'right-end' |
Positions;
const fallbackOrderVariant = {
start: 'sem',
middle: 'mse',
end: 'esm',
};
const fallbackOrder = {
top: 'tbrl',
bottom: 'btrl',
right: 'rltb',
left: 'lrbt',
};
const getParents = function(element: Element) : Array<Element | Window> {
// Set up a parent array
const parents = [];
for (let el = element.parentNode; el && el !== document; el = el.parentNode) {
parents.push(el);
}
return parents.filter((element) => element.nodeType !== Node.TEXT_NODE);
};
function getScrollParents(element: Element): Array<Element | Window> {
const parents = getParents(element);
return parents;
}
const useBoundingClientRect = (element: RefObject<Element>, watch = false, callback) : void => useEffect(() => {
if (!element.current) {
return;
}
callback();
if (!watch) {
return;
}
const parents = getScrollParents(element.current);
const passive = { passive: true };
window.addEventListener('resize', callback);
parents.forEach((el) => el.addEventListener('scroll', callback, passive));
return () => {
window.removeEventListener('resize', callback);
parents.forEach((el) => el.removeEventListener('scroll', callback));
};
}, [watch, callback]);
export const getPositionStyle = ({ placement = 'bottom-start', container, targetBoundaries, variantStore, target } : { placement: Placements, target: DOMRect, container: DOMRect, targetBoundaries: Boundaries, variantStore?: VariantBoundaries }) : PositionStyle | null => {
if (!targetBoundaries) {
return;
}
const { top, left, bottom, right } = container;
const [placementKey, variantKey = 'middle'] = placement.split('-');
const placementAttempts = fallbackOrder[placementKey];
const variantsAttempts = fallbackOrderVariant[variantKey];
for (const placementAttempt of placementAttempts) {
const directionVertical = ['t', 'b'].includes(placementAttempt);
const [positionKey, variantKey] = directionVertical ? ['top', 'left'] : ['left', 'top'];
const point = targetBoundaries[placementAttempt];
const [positionBox, variantBox] = directionVertical ? [target.height, target.width] : [target.width, target.height];
const [positionMaximum, variantMaximum] = directionVertical ? [bottom, right] : [right, bottom];
const [positionMinimum, variantMinimum] = directionVertical ? [top, left] : [left, top];
// if the point extrapolate the container boundaries
if (point < positionMinimum || point + positionBox > positionMaximum) {
continue;
}
for (const v of variantsAttempts) {
// The position-value, the related size value of the popper and the limit
const variantPoint = variantStore[`${ directionVertical ? 'v' : 'h' }${ v }`];
if (variantPoint < variantMinimum || (variantPoint + variantBox) > variantMaximum) {
continue;
}
const style = {
[positionKey]: `${ point }px`,
[variantKey]: `${ variantPoint }px`,
position: 'fixed',
zIndex: '9999',
opacity: 1,
} as PositionStyle;
return style;
}
}
const placementAttempt = targetBoundaries[placementAttempts[0]];
const directionVertical = ['t', 'b'].includes(placementAttempt);
const point = targetBoundaries[placementAttempt];
const variantPoint = variantStore[`${ directionVertical ? 'v' : 'h' }${ variantsAttempts[0] }`];
return {
top: `${ point }px`,
left: `${ variantPoint }px`,
position: 'fixed',
zIndex: '9999',
opacity: 1,
};
};
export const getTargetBoundaries = ({ referenceBox, target, margin = 0 } : { referenceBox?: DOMRect, target?: DOMRect, margin?: number }) : Boundaries | null => referenceBox && target && {
t: referenceBox.top - target.height - margin,
b: referenceBox.bottom + margin,
r: referenceBox.right + margin,
l: referenceBox.left - target.width - margin,
};
export const getVariantBoundaries = ({ referenceBox, target } : { referenceBox?: DOMRect, target?: DOMRect }) : VariantBoundaries | null => referenceBox && target && {
vm: (-target.width / 2) + (referenceBox.left + referenceBox.width / 2),
vs: referenceBox.left,
ve: referenceBox.left + referenceBox.width - target.width,
hs: referenceBox.bottom - referenceBox.height,
he: referenceBox.bottom - target.height,
hm: referenceBox.bottom - referenceBox.height / 2 - target.height / 2,
};
/**
* Hook to deal and position an element using an anchor
* @param reference - the anchor
* @param targetEl - the element to be positioned
* @param options - options to position
* @returns The style containing top and left position
* @public
*/
export const usePosition = (reference: RefObject<Element>, target: RefObject<Element>, options: PositionOptions) : PositionStyle | null => {
<