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

feat: horizontal mode #124

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
Open
62 changes: 43 additions & 19 deletions packages/core/src/Collapse.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { uid, getAutoHeightDuration, paddingWarning } from './utils'
import { uid, getAutoDimensionDuration, paddingWarning } from './utils'

type Style = Partial<CSSStyleDeclaration>

export interface CollapseParams {
/** If true, the collapse element will initialize expanded */
defaultExpanded?: boolean
/** Height in pixels that the collapse element collapses to */
collapsedHeight?: number
collapsedDimension?: number
SaidMarar marked this conversation as resolved.
Show resolved Hide resolved
/** Styles applied to the collapse upon expanding */
expandStyles?: Style
/** Styles applied to the collapse upon collapsing */
Expand All @@ -22,6 +22,8 @@ export interface CollapseParams {
hasDisabledAnimation?: boolean
/** Unique ID used for accessibility */
id?: string
/** Horizontal mode expand/collapse width */
isHorizontal?: boolean
SaidMarar marked this conversation as resolved.
Show resolved Hide resolved
/** Handler called when the expanded state changes */
onExpandedChange?: (state: boolean) => void
/** Handler called when the collapse transition starts */
Expand All @@ -44,10 +46,14 @@ export class Collapse {
private id!: string
private collapseElement: HTMLElement | null | undefined = null
private isMounted = false
private dimension: 'height' | 'width'
private initialWidth = 0
private initialHeight = 0
SaidMarar marked this conversation as resolved.
Show resolved Hide resolved

constructor(params: CollapseParams) {
this.setOptions(params)
this.isExpanded = Boolean(this.options.defaultExpanded)
this.dimension = this.options.isHorizontal ? 'width' : 'height'
this.init()
this.isMounted = true
}
Expand All @@ -56,16 +62,21 @@ export class Collapse {
const collapseElement = this.options.getCollapseElement()
if (this.collapseElement !== collapseElement) {
this.collapseElement = collapseElement
this.setInitialDimensions()
if (!this.isExpanded) {
this.setStyles(this.getCollapsedStyles())
this.setStyles({
...this.getCollapsedStyles(),
// When expand = false and collapsing width we preserve initial height
...(this.options.isHorizontal ? {height: `${this.initialHeight}px`} : {}),
Copy link
Owner

Choose a reason for hiding this comment

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

I think this might be an issue with an element that's made the full height of the window. An example would be a drawer or a sidebar. Upon resizing, hardcoding the height would cause the sidebar to not stay adhered to the window edge.

We might need to use a resizeobserver in order to update the height

Copy link
Owner

Choose a reason for hiding this comment

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

Or maybe it should be an option to persist the height when the width is animated and collapsed? Not sure which is better

Copy link
Author

@SaidMarar SaidMarar Jan 16, 2023

Choose a reason for hiding this comment

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

There is two places where we persist the height:

  • In the init method when the default expand is equal to false.
  • In the close method when the width is animated and collapsed.

If we are going to use resizeobserver (observe height) on the element it will interfere with collapase when it is changing the height for animated width.

I think the second option you mentioned is already handled in close method and for me i go for this option, but if you have any suggestions to improve it let me know please ;)

Copy link
Owner

Choose a reason for hiding this comment

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

I pulled up the storybook, and it looks like the fixed height is always applied (it would need to be removed in the transitionend handler. I made an example in the example app that shows this quick and dirty sidebar demo.

sidebar demo

Copy link
Author

Choose a reason for hiding this comment

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

Good point, i think we should reset height on transitionEnd then I'll check other scenarios

Copy link
Author

@SaidMarar SaidMarar Jan 17, 2023

Choose a reason for hiding this comment

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

Actually responsive and fixed value of height will work perfectly without saving initial height.
The only issue that we will have is with the auto height, it is changing while animating width, so i think in this case it is up to the user to set the option collapseStyles what do you think ? I tried a solution by adding a setTimeout to detect if height is changed at middle of transition and it works ! but if the default expand is false at the initialization there is no chance to have this information ^^

colla

})
}
}
}

private getCollapsedStyles = (): Style => {
return {
display: this.options.collapsedHeight === 0 ? 'none' : 'block',
height: `${this.options.collapsedHeight}px`,
display: this.options.collapsedDimension === 0 ? 'none' : 'block',
[this.dimension]: `${this.options.collapsedDimension}px`,
overflow: 'hidden',
}
}
Expand All @@ -85,8 +96,9 @@ export class Collapse {
expandStyles: {},
collapseStyles: {},
hasDisabledAnimation: false,
collapsedHeight: 0,
collapsedDimension: 0,
defaultExpanded: false,
isHorizontal: false,
onExpandedChange() {},
...opts,
}
Expand All @@ -109,25 +121,25 @@ export class Collapse {
}
}

private getTransitionStyles = (height: number | string) => {
private getTransitionStyles = (dimension: number | string) => {
if (this.options.hasDisabledAnimation) {
return ''
}
const duration =
this.options.duration === 'auto'
? getAutoHeightDuration(height)
? getAutoDimensionDuration(dimension)
: this.options.duration
return `height ${duration}ms ${this.options.easing}`
return `${this.dimension} ${duration}ms ${this.options.easing}`
}

private handleTransitionEnd = (e: TransitionEvent) => {
if (e.propertyName !== 'height') {
if (e.propertyName !== this.dimension) {
return
}

if (this.isExpanded) {
this.setStyles({
height: '',
[this.dimension]: '',
overflow: '',
transition: '',
display: '',
Expand All @@ -142,6 +154,16 @@ export class Collapse {
}
}

private setInitialDimensions = () => {
const collapseElement = this.options.getCollapseElement()
if (this.initialWidth === 0) {
this.initialWidth = collapseElement?.scrollWidth || 0
}
if (this.initialHeight === 0) {
this.initialHeight = collapseElement?.scrollHeight || 0
}
}

open = (): void => {
// don't repeat if already open
if (this.isExpanded || !this.isMounted) {
Expand All @@ -162,14 +184,14 @@ export class Collapse {
...this.options.expandStyles,
display: 'block',
overflow: 'hidden',
height: `${this.options.collapsedHeight}px`,
[this.dimension]: `${this.options.collapsedDimension}px`,
})
requestAnimationFrame(() => {
const height = target.scrollHeight
const dimensionValue = this.options.isHorizontal ? this.initialWidth : target.scrollHeight

// Order important! So setting properties directly
target.style.transition = this.getTransitionStyles(height)
target.style.height = `${height}px`
target.style.transition = this.getTransitionStyles(dimensionValue)
target.style[this.dimension] = `${dimensionValue}px`
})
})
}
Expand All @@ -194,16 +216,18 @@ export class Collapse {
this.options.onExpandedChange?.(false)
this.options.onCollapseStart?.()
requestAnimationFrame(() => {
const height = target.scrollHeight
const dimensionValue = this.options.isHorizontal ? target.scrollWidth : target.scrollHeight
this.setStyles({
...this.options.collapseStyles,
transition: this.getTransitionStyles(height),
height: `${height}px`,
transition: this.getTransitionStyles(dimensionValue),
[this.dimension]: `${dimensionValue}px`,
})
requestAnimationFrame(() => {
this.setStyles({
height: `${this.options.collapsedHeight}px`,
[this.dimension]: `${this.options.collapsedDimension}px`,
overflow: 'hidden',
// when collapsing width we make sure to preserve the initial height
...(this.options.isHorizontal ? {height: `${this.initialHeight}px`} : {}),
})
})
})
Expand Down
6 changes: 3 additions & 3 deletions packages/core/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,12 @@ export const callAll =
fns.forEach((fn) => fn && fn(...args))

// https://github.com/mui-org/material-ui/blob/da362266f7c137bf671d7e8c44c84ad5cfc0e9e2/packages/material-ui/src/styles/transitions.js#L89-L98
export function getAutoHeightDuration(height: number | string): number {
if (!height || typeof height === 'string') {
export function getAutoDimensionDuration(dimension: number | string): number {
if (!dimension || typeof dimension === 'string') {
return 0
}

const constant = height / 36
const constant = dimension / 36

// https://www.wolframalpha.com/input/?i=(4+%2B+15+*+(x+%2F+36+)+**+0.25+%2B+(x+%2F+36)+%2F+5)+*+10
return Math.round((4 + 15 * constant ** 0.25 + constant / 5) * 10)
Expand Down
8 changes: 5 additions & 3 deletions packages/react/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ A React hook for creating accessible expand/collapse components. Animates the he

## Features

- Handles the height of animations of your elements, `auto` included!
- Handles the height/width of animations of your elements, `auto` included!
- You control the UI - `useCollapse` provides the necessary props, you control the styles and the elements.
- Accessible out of the box - no need to worry if your collapse/expand component is accessible, since this takes care of it for you!
- No animation framework required! Simply powered by CSS animations
Expand Down Expand Up @@ -81,9 +81,10 @@ const { getCollapseProps, getToggleProps, isExpanded, setExpanded } =
useCollapse({
isExpanded: boolean,
defaultExpanded: boolean,
isHorizontal: boolean,
expandStyles: {},
collapseStyles: {},
collapsedHeight: 0,
collapsedDimension: 0,
easing: string,
duration: number,
onCollapseStart: func,
Expand All @@ -101,9 +102,10 @@ The following are optional properties passed into `useCollapse({ })`:
| -------------------- | -------- | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| isExpanded | boolean | `undefined` | If true, the Collapse is expanded |
| defaultExpanded | boolean | `false` | If true, the Collapse will be expanded when mounted |
| isHorizontal | boolean | `false` | If true, the component will collapse/expand horizontally |
| expandStyles | object | `{}` | Style object applied to the collapse panel when it expands |
| collapseStyles | object | `{}` | Style object applied to the collapse panel when it collapses |
| collapsedHeight | number | `0` | The height of the content when collapsed |
| collapsedDimension | number | `0` | The height/width of the content when collapsed |
| easing | string | `cubic-bezier(0.4, 0, 0.2, 1)` | The transition timing function for the animation |
| duration | number | `undefined` | The duration of the animation in milliseconds. By default, the duration is programmatically calculated based on the height of the collapsed element |
| onCollapseStart | function | no-op | Handler called when the collapse animation begins |
Expand Down
11 changes: 11 additions & 0 deletions packages/react/src/stories/basic.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,17 @@ export const Controlled = () => {
)
}

export const HorizontalMode = () => {
const { getToggleProps, getCollapseProps, isExpanded } = useCollapse({ isHorizontal: true })

return (
<div>
<Toggle {...getToggleProps()}>{isExpanded ? 'Close' : 'Open'}</Toggle>
<Collapse {...getCollapseProps()}>{excerpt}</Collapse>
</div>
)
}

function useReduceMotion() {
const [matches, setMatch] = React.useState(
window.matchMedia('(prefers-reduced-motion: reduce)').matches
Expand Down