Skip to content

Commit

Permalink
feat(slider, range-slider): add pin feature (VIV-1611) (#1736)
Browse files Browse the repository at this point in the history
  • Loading branch information
RichardHelm authored Jul 24, 2024
1 parent 78f4f4a commit 74944b4
Show file tree
Hide file tree
Showing 32 changed files with 1,269 additions and 57 deletions.
16 changes: 16 additions & 0 deletions libs/components/src/lib/popup/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,22 @@ Use the `placement` attribute to set the placement of the popup around the ancho
</script>
```

### Placement Strategy

Controls the placement strategy of the popup. The `Flip` strategy will flip the popup to the opposite side if there is not enough space. The `AutoPlacementHorizontal` and `AutoPlacementVertical` strategies will place the popup on the side with the most space in the respective axis.

- Type: `PlacementStrategy.Flip` | `PlacementStrategy.AutoPlacementHorizontal` | `PlacementStrategy.AutoPlacementVertical`
- Default: `PlacementStrategy.Flip`

### Animation Frame

Whether to update the position of the floating element on every animation frame if required.

See the [Floating UI Documentation](https://floating-ui.com/docs/autoUpdate#animationframe) for more information.

- Type: `boolean`
- Default: `false`

### Strategy

Use the `strategy` attribute to set the placement strategy.
Expand Down
178 changes: 171 additions & 7 deletions libs/components/src/lib/popup/popup.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
} from '@vivid-nx/shared';
import * as floatingUI from '@floating-ui/dom';
import type { Button } from '../button/button';
import { Popup } from './popup';
import { PlacementStrategy, Popup } from './popup';
import '.';

const COMPONENT_TAG = 'vwc-popup';
Expand All @@ -18,8 +18,8 @@ describe('vwc-popup', () => {

async function setupPopupToOpenWithAnchor() {
element.anchor = anchor;
await elementUpdated(element);
element.open = true;
await elementUpdated(element);
return anchor;
}

Expand All @@ -29,11 +29,11 @@ describe('vwc-popup', () => {
'<vwc-button id="anchor"></vwc-button>',
ADD_TEMPLATE_TO_FIXTURE
)) as Button;
global.ResizeObserver = jest.fn().mockImplementation(() => ({
observe: jest.fn(),
unobserve: jest.fn(),
disconnect: jest.fn(),
}));
global.ResizeObserver = class {
observe = jest.fn();
unobserve = jest.fn();
disconnect = jest.fn();
};
});

afterEach(function () {
Expand Down Expand Up @@ -156,10 +156,43 @@ describe('vwc-popup', () => {
expect(element.dismissible).toBeFalsy();
expect(element.anchor).toBeUndefined();
expect(element.placement).toBeUndefined();
expect(element.placementStrategy).toBe(PlacementStrategy.Flip);
expect(element.animationFrame).toBe(false);
expect(element.strategy).toEqual('fixed');
});
});

describe('open', () => {
beforeEach(() => {
jest.spyOn(floatingUI, 'autoUpdate');
});

afterEach(() => {
jest.mocked(floatingUI.autoUpdate).mockRestore();
});

it('should hide control if not set', async () => {
expect(getControlElement(element).classList).not.toContain('open');
});

it('should show control if set', async () => {
element.open = true;
await elementUpdated(element);

expect(getControlElement(element).classList).toContain('open');
});

it('should begin to auto update after DOM is updated', async function () {
element.anchor = anchor;
element.open = true;
const updateCallsBeforeDOMUpdate = jest.mocked(floatingUI.autoUpdate).mock
.calls.length;
await elementUpdated(element);
expect(updateCallsBeforeDOMUpdate).toBe(0);
expect(floatingUI.autoUpdate).toHaveBeenCalledTimes(2);
});
});

describe('show', () => {
it('should set "open" to true', async () => {
element.show();
Expand Down Expand Up @@ -254,6 +287,137 @@ describe('vwc-popup', () => {
});
});

describe('placementStrategy', () => {
beforeEach(() => {
jest.spyOn(floatingUI, 'computePosition');
});

afterEach(() => {
jest.mocked(floatingUI.computePosition).mockRestore();
});

it('should use placementStrategy to compute position', async () => {
element.placementStrategy = PlacementStrategy.AutoPlacementHorizontal;

await setupPopupToOpenWithAnchor();
await element.updatePosition();

expect(floatingUI.computePosition).toHaveBeenLastCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({
middleware: expect.arrayContaining([
expect.objectContaining({
name: 'autoPlacement',
}),
]),
})
);
});
});

describe('animationFrame', () => {
function resetMethodCallCount(property: any) {
jest.spyOn(element, property).mockReset();
}

async function openPopup() {
element.open = true;
await elementUpdated(element);
}

function getLastFrameCallback() {
return rAFStub.mock.lastCall[0];
}

function callLastFrameCallback() {
getLastFrameCallback()();
}

function setElementClientRect(overrides = {}) {
const clientRect = {
x: 4,
y: 4,
width: 1,
height: 1,
top: 1,
right: 1,
bottom: 1,
left: 1,
} as DOMRect;
jest
.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
.mockReturnValue({ ...clientRect, ...overrides });
}

let rAFStub: any;

beforeEach(async () => {
element.anchor = anchor;
await elementUpdated(element);
rAFStub = jest.spyOn(window, 'requestAnimationFrame');
});

afterEach(() => {
jest.mocked(window.requestAnimationFrame).mockRestore();
});

it('should disable recursive calls to requestAnimationFrame when false', async () => {
await openPopup();
const cb = getLastFrameCallback();
rAFStub.mockReset();
cb();

expect(rAFStub).toHaveBeenCalledTimes(0);
});

it('should call rAF recursively when true', async () => {
element.animationFrame = true;
await openPopup();
const cb = getLastFrameCallback();
rAFStub.mockReset();
cb();
cb();
expect(rAFStub).toHaveBeenCalledTimes(2);
expect(getLastFrameCallback()).toBe(cb);
});

it("should prevent call to updatePosition if position or size didn't change", async () => {
setElementClientRect({ width: 100, top: 100 });
element.animationFrame = true;
await openPopup();
resetMethodCallCount('updatePosition');

callLastFrameCallback();

expect(element.updatePosition).toBeCalledTimes(0);
});

it('should updatePosition if size changes', async () => {
setElementClientRect({ width: 300 });
element.animationFrame = true;
await openPopup();
resetMethodCallCount('updatePosition');
setElementClientRect({ width: 400 });

callLastFrameCallback();

expect(element.updatePosition).toBeCalledTimes(1);
});

it('should updatePosition on next frame if position changes', async () => {
setElementClientRect({ top: 100 });
element.animationFrame = true;
await openPopup();
resetMethodCallCount('updatePosition');
setElementClientRect({ top: 200 });

callLastFrameCallback();

expect(element.updatePosition).toBeCalledTimes(1);
});
});

describe('a11y', () => {
it('should pass html a11y test', async () => {
element.open = true;
Expand Down
70 changes: 65 additions & 5 deletions libs/components/src/lib/popup/popup.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { attr, observable } from '@microsoft/fast-element';
import { attr, DOM, observable } from '@microsoft/fast-element';
import { FoundationElement } from '@microsoft/fast-foundation';
import {
arrow,
autoPlacement,
autoUpdate,
computePosition,
flip,
Expand All @@ -12,6 +13,37 @@ import {
} from '@floating-ui/dom';
import type { Placement, Strategy } from '@floating-ui/dom';

export const PlacementStrategy = {
Flip: 'flip',
AutoPlacementHorizontal: 'auto-placement-horizontal',
AutoPlacementVertical: 'auto-placement-vertical',
} as const;
type PlacementStrategyId =
typeof PlacementStrategy[keyof typeof PlacementStrategy];
const placementStrategyMiddlewares = {
[PlacementStrategy.Flip]: flip(),
[PlacementStrategy.AutoPlacementHorizontal]: autoPlacement({
allowedPlacements: [
'bottom',
'top',
'bottom-start',
'bottom-end',
'top-start',
'top-end',
],
}),
[PlacementStrategy.AutoPlacementVertical]: autoPlacement({
allowedPlacements: [
'left',
'right',
'left-start',
'left-end',
'right-start',
'right-end',
],
}),
} as const;

/**
* @public
* @component popup
Expand All @@ -22,7 +54,7 @@ export class Popup extends FoundationElement {
get #middleware(): Array<any> {
let middleware = [
inline(),
flip(),
placementStrategyMiddlewares[this.placementStrategy],
hide(),
size({
apply({ availableWidth, availableHeight, elements }) {
Expand Down Expand Up @@ -61,7 +93,7 @@ export class Popup extends FoundationElement {
open = false;
openChanged(_: boolean, newValue: boolean): void {
newValue ? this.$emit('vwc-popup:open') : this.$emit('vwc-popup:close');
this.#updateAutoUpdate();
DOM.queueUpdate(() => this.#updateAutoUpdate());
}

/**
Expand Down Expand Up @@ -105,6 +137,29 @@ export class Popup extends FoundationElement {
*/
@attr({ mode: 'fromView' }) placement?: Placement;

/**
* The placement strategy of the popup.
*
* @public
*/
placementStrategy: PlacementStrategyId = PlacementStrategy.Flip;

/**
* Whether to update the position of the floating element on every animation frame if required.
*
* @public
* HTML Attribute: animation-frame
*/
@attr({ mode: 'boolean', attribute: 'animation-frame' }) animationFrame =
false;

/**
* @internal
*/
animationFrameChanged() {
this.#updateAutoUpdate();
}

/**
* the strategy of the popup
*
Expand Down Expand Up @@ -140,8 +195,13 @@ export class Popup extends FoundationElement {
#updateAutoUpdate() {
this.#cleanup?.();
if (this.anchorEl && this.open && this.popupEl) {
this.#cleanup = autoUpdate(this.anchorEl, this.popupEl, () =>
this.updatePosition()
this.#cleanup = autoUpdate(
this.anchorEl,
this.popupEl,
() => this.updatePosition(),
{
animationFrame: this.animationFrame,
}
);
}
}
Expand Down
Loading

0 comments on commit 74944b4

Please sign in to comment.