Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Controller: @phase2/outline-controller-resize-controller #393

Open
wants to merge 7 commits into
base: next
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions packages/controllers/resize-controller/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# Resize Controller

The `resize-controller` is a utility module that allows you to observe and react to changes in the size of a web component. It provides a simple way to handle resize events and perform actions based on the new size of the component.

## Installation

You can install the `resize-controller` package using yarn:

```
yarn add -D @phase2/outline-controller-resize-controller
```

## Usage

To use the `resize-controller` in your web component, follow these steps:

1. Import the necessary classes and functions from the `lit` package:

```javascript
import { ResizeController } from '@phase2/outline-controller-resize-controller';
```

2. Create an instance of the `ResizeController` and pass the host element and options:

```javascript
resizeController = new ResizeController(this);
```

## API Reference

### `ResizeController`

The `ResizeController` class provides methods to observe resize events and perform actions based on the new size of the host element.

#### Constructor

```javascript
new ResizeController(host: ReactiveControllerHost & HTMLElement, options?: ResizeControllerOptions)
```

- `host`: The host element of the web component.
- `options` (optional): An object specifying the options for the `ResizeController`. It can include the following properties:
- `debounce`: The delay in milliseconds to debounce the resize event. Defaults to `200`.
- `breakpoints`: An array of breakpoints for different size ranges. Defaults to `[768]`.
- `elementToRerender`: The element to trigger a re-render when the size changes. Defaults to the `host` element.

#### Properties

- `onResize`: A callback function that will be called when the element is resized. Override this method in your component to handle the resize event.

#### Methods

- `hostConnected()`: Called when the host element is connected to the DOM. Observes the element for size changes.
- `hostDisconnected()`: Called when the host element is disconnected from the DOM. Stops observing size changes.

## Example

Here's an example that demonstrates how to use the `resize-controller` in a web component:

```javascript
@customElement('my-component')
export class MyComponent extends LitElement {
resizeController = new ResizeController(this, {
breakpoints: [768, 1440],
});

render() {
const Classes = {
'mobile': this.resizeController.currentBreakpointRange === 0,
'medium': this.resizeController.currentBreakpointRange === 1,
'large': this.resizeController.currentBreakpointRange === 2,
};
return html`
<div class="${classMap(Classes)}">
Hello World
</div>
`;
}
}

declare global {
interface HTMLElementTagNameMap {
'my-component': MyComponent;
}
}
```

In this example, `resizeController` is initialized to support the following breakpoints:

- 0: 0-767px
- 1: 768px-1439px
- 2: 1440px - 100000px

When my-component's width crosses from one range to another, the resize controller will call the component's `render()` function.
1 change: 1 addition & 0 deletions packages/controllers/resize-controller/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { ResizeController } from './src/resize-controller';
41 changes: 41 additions & 0 deletions packages/controllers/resize-controller/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
{
"name": "@phase2/outline-controller-resize-controller",
"version": "0.0.0",
"description": "Controller to help with managing classes / markup updates based on component's width",
"keywords": [
"outline components",
"outline design",
"resize"
],
"main": "index.ts",
"types": "index.ts",
"typings": "index.d.ts",
"files": [
"/dist/",
"/src/",
"!/dist/tsconfig.build.tsbuildinfo"
],
"author": "Phase2 Technology",
"repository": {
"type": "git",
"url": "https://github.com/phase2/outline.git",
"directory": "packages/controllers/resize-controller"
},
"license": "BSD-3-Clause",
"scripts": {
"build": "node ../../../scripts/build.js",
"package": "yarn publish"
},
"dependencies": {
"lit": "^2.3.1"
},
"devDependencies": {
"tslib": "^2.1.0"
},
"publishConfig": {
"access": "public"
},
"exports": {
".": "./index.ts"
}
}
186 changes: 186 additions & 0 deletions packages/controllers/resize-controller/src/resize-controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
import { ReactiveControllerHost, ReactiveController } from 'lit';

/**
* Debounces a function
* @template T
* @param {T} func - The function to debounce
* @param {number} delay - The delay in milliseconds
* @param {boolean} [immediate=false] - Whether to execute the function immediately
* @returns {(...args: Parameters<T>) => void} - The debounced function
*/
export const debounce = <T extends (...args: Parameters<T>) => void>(
func: T,
delay: number,
immediate = false
): ((...args: Parameters<T>) => void) => {
let timeoutId: ReturnType<typeof setTimeout> | undefined = undefined;

return function debounced(...args: Parameters<T>) {
const executeFunc = () => func(...args);

clearTimeout(timeoutId);

if (immediate && timeoutId === undefined) {
executeFunc();
}

timeoutId = setTimeout(executeFunc, delay);
};
};
himerus marked this conversation as resolved.
Show resolved Hide resolved

export type breakpointsRangeType = {
min: number;
max: number;
};

/**
* ResizeController class
* @implements {ReactiveController}
*/
export class ResizeController implements ReactiveController {
host: ReactiveControllerHost & HTMLElement;
resizeObserver: ResizeObserver;
elementToObserve: Element;
options: {
debounce: number;
breakpoints: number[];
elementToRerender: ReactiveControllerHost & HTMLElement;
};
currentComponentWidth: number;
currentBreakpointRange: number;
breakpointsRangeArray: breakpointsRangeType[] = [];

/**
* Create a constructor that takes a host and options
* @param {ReactiveControllerHost & Element} host - The host element
* @param {{debounce?: number; breakpoints?: number[]}} [options={}] - The options object
*/
constructor(
host: ReactiveControllerHost & HTMLElement,
options: {
debounce?: number;
breakpoints?: number[];
elementToRerender?: ReactiveControllerHost & HTMLElement;
} = {}
) {
const defaultOptions = {
debounce: 200,
breakpoints: [768],
elementToRerender: host,
};

/**
* Remove any undefined variables from options object
*/
const filteredOptionsObject = Object.fromEntries(
Object.entries(options).filter(([_, value]) => value !== undefined)
);
this.options = { ...defaultOptions, ...filteredOptionsObject };

this.host = host;
this.host.addController(this);

this.initializeBreakpointsRangeType();
}

/**
* Initialize the breakpoints range array
*
* The default breakpoints array ([768]) will create this breakpoints range array:
* [{min: 0, max: 767}, {min: 768, max: 100000}]
*
* If custom breakpoints array is provided, (for example [768, 1200, 2000]) this breakpoints range array will be created:
* [{min: 0, max: 767}, {min: 768, max: 1199}, {min: 1200, max: 1999}, {min: 2000, max: 100000}]
*
*/
initializeBreakpointsRangeType() {
// This will allow create an additional breakpoint from the last custom breakpoint to 100000
this.options.breakpoints?.push(100000);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Modifying the options object directly by pushing to breakpoints array can lead to unexpected side effects.

- this.options.breakpoints?.push(100000);
+ const extendedBreakpoints = [...this.options.breakpoints, 100000];
+ this.options = { ...this.options, breakpoints: extendedBreakpoints };

Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation.

Suggested change
this.options.breakpoints?.push(100000);
const extendedBreakpoints = [...this.options.breakpoints, 100000];
this.options = { ...this.options, breakpoints: extendedBreakpoints };


let minBreakpoint = 0;
this.options.breakpoints?.forEach(breakpoint => {
const newBreakpointRange = {
min: minBreakpoint,
max: breakpoint - 1,
};
minBreakpoint = breakpoint;
this.breakpointsRangeArray.push(newBreakpointRange);
});
}

/**
* Called when the host element is connected to the DOM
*/
hostConnected() {
if (!this.host.style.display) {
// adding `display: block` to :host of component
this.host.style.setProperty(
'display',
'var(--style-added-by-resize-controller, block)'
);
Comment on lines +115 to +120
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Setting display: block directly on the host element may not be appropriate for all components and could override existing styles.

Consider using a more flexible approach or documenting this behavior clearly to inform users.

}

// Create a new ResizeObserver and pass in the function to be called when the element is resized
this.resizeObserver = new ResizeObserver(
(entries: ResizeObserverEntry[]) => {
// Create a debounced version of the onElementResize function
debounce(
this.onElementResize.bind(this),
this.options.debounce
)(entries);
}
);
Comment on lines +123 to +132
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The debounced function is recreated on every resize event, which negates the purpose of debouncing.

Move the debounced function creation outside of the ResizeObserver callback to ensure it's created only once.

+ const debouncedOnElementResize = debounce(
+   this.onElementResize.bind(this),
+   this.options.debounce
+ );
this.resizeObserver = new ResizeObserver(
  (entries: ResizeObserverEntry[]) => {
-     debounce(
-       this.onElementResize.bind(this),
-       this.options.debounce
-     )(entries);
+     debouncedOnElementResize(entries);
  }
);

Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation.

Suggested change
// Create a new ResizeObserver and pass in the function to be called when the element is resized
this.resizeObserver = new ResizeObserver(
(entries: ResizeObserverEntry[]) => {
// Create a debounced version of the onElementResize function
debounce(
this.onElementResize.bind(this),
this.options.debounce
)(entries);
}
);
// Create a new ResizeObserver and pass in the function to be called when the element is resized
const debouncedOnElementResize = debounce(
this.onElementResize.bind(this),
this.options.debounce
);
this.resizeObserver = new ResizeObserver(
(entries: ResizeObserverEntry[]) => {
debouncedOnElementResize(entries);
}
);


// Get a reference to the element you want to observe
this.elementToObserve = this.host;

// Observe the element for size changes
this.resizeObserver.observe(this.elementToObserve);
}

/**
* Called when the host element is disconnected from the DOM
*/
hostDisconnected() {
this.resizeObserver.disconnect();
}

/**
* Called when the element is resized
* @param {ResizeObserverEntry[]} _entries - The ResizeObserverEntry array
*/
onElementResize(_entries: ResizeObserverEntry[]) {
this.currentComponentWidth = _entries[0].contentRect.width;

// skip if width is not yet set
if (this.currentComponentWidth) {
this.calculateNewBreakpointRange();
} else if (this.currentComponentWidth === 0) {
// eslint-disable-next-line no-console
console.warn(
`resize-controller: No width detected in <${this.host.localName}>. Please confirm it has display: block`
);
}
Comment on lines +158 to +163
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Logging a warning to the console for a width of 0 may not always indicate an issue, especially for initially hidden elements.

Consider a more robust way to handle or document this scenario to avoid confusion.

}

/**
* Calculate the new breakpoint based on the current width
*/
calculateNewBreakpointRange() {
let newBreakpointRange = this.currentBreakpointRange;

this.breakpointsRangeArray.forEach((breakpoint, index) => {
if (
this.currentComponentWidth >= breakpoint.min &&
this.currentComponentWidth <= breakpoint.max
) {
newBreakpointRange = index;
}
});

if (newBreakpointRange !== this.currentBreakpointRange) {
this.currentBreakpointRange = newBreakpointRange;
this.options.elementToRerender.requestUpdate();
}
}
}
9 changes: 9 additions & 0 deletions packages/controllers/resize-controller/tsconfig.build.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "./dist"
},
"include": ["index.ts", "src/**/*", "tests/**/*"],
"references": [{ "path": "../../outline-core/tsconfig.build.json" }]
}
Loading