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
Show file tree
Hide file tree
Changes from all 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
50 changes: 49 additions & 1 deletion example/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export const Uncontrolled = () => {

return (
<div>
<h2>Uncontrolled</h2>
<button {...getToggleProps({ style: { marginRight: 4 } })}>
{isExpanded ? 'Close' : 'Open'}
</button>
Expand All @@ -32,10 +33,57 @@ export const Uncontrolled = () => {
)
}

const App = () => {
const Sidebar = () => {
const [showExample, setShowExample] = React.useState(false)
const { getToggleProps, getCollapseProps, isExpanded } = useCollapse({
axis: 'horizontal',
collapsedDimension: 75,
})

let content: JSX.Element
if (!showExample) {
content = <button onClick={() => setShowExample(true)}>Show Example</button>
} else {
content = (
<div>
<div
{...getCollapseProps()}
style={{ position: 'fixed', inset: 0, maxWidth: 300 }}
>
<div
style={{
display: 'flex',
flexDirection: 'column',
height: '100%',
backgroundColor: 'cyan',
}}
>
<button {...getToggleProps()}>
{isExpanded ? 'Close' : 'Open'}
</button>
<button onClick={() => setShowExample(false)}>
Disable example
</button>
<div style={{ marginTop: 'auto' }}>sit at the bottom</div>
</div>
</div>
</div>
)
}

return (
<div>
<h2>Sidebar</h2>
{content}
</div>
)
}

const App = () => {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 15 }}>
<Uncontrolled />
<Sidebar />
</div>
)
}
Expand Down
67 changes: 49 additions & 18 deletions packages/core/src/Collapse.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
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 */
/** @deprecated will be replaced by collapsedDimension
* Height in pixels that the collapse element collapses to */
collapsedHeight?: number
/** Dimension in pixels that the collapse element collapses to */
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 +25,8 @@ export interface CollapseParams {
hasDisabledAnimation?: boolean
/** Unique ID used for accessibility */
id?: string
/** Vertical/Horizontal mode expand/collapse height/width, default value is vertical */
axis?: 'vertical' | 'horizontal'
/** Handler called when the expanded state changes */
onExpandedChange?: (state: boolean) => void
/** Handler called when the collapse transition starts */
Expand All @@ -44,10 +49,15 @@ export class Collapse {
private id!: string
private collapseElement: HTMLElement | null | undefined = null
private isMounted = false
private isVertical = true
private dimension: 'height' | 'width'
private initialDimensions = { height: 0, width: 0 }

constructor(params: CollapseParams) {
this.setOptions(params)
this.isExpanded = Boolean(this.options.defaultExpanded)
this.isVertical = this.options.axis === 'vertical'
this.dimension = this.isVertical ? 'height' : 'width'
this.init()
this.isMounted = true
}
Expand All @@ -56,16 +66,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.isVertical ? {} : { height: `${this.initialDimensions.height}px` }),
})
}
}
}

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 @@ -86,11 +101,15 @@ export class Collapse {
collapseStyles: {},
hasDisabledAnimation: false,
collapsedHeight: 0,
collapsedDimension: 0,
defaultExpanded: false,
axis: 'vertical',
onExpandedChange() {},
...opts,
}

this.options.collapsedDimension = this.isVertical ? this.options.collapsedHeight : this.options.collapsedDimension

this.id = this.options.id ?? `collapse-${uid(this)}`
}

Expand All @@ -109,25 +128,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 +161,16 @@ export class Collapse {
}
}

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

open = (): void => {
// don't repeat if already open
if (this.isExpanded || !this.isMounted) {
Expand All @@ -162,14 +191,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.isVertical ? target.scrollHeight : this.initialDimensions.width

// 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 +223,18 @@ export class Collapse {
this.options.onExpandedChange?.(false)
this.options.onCollapseStart?.()
requestAnimationFrame(() => {
const height = target.scrollHeight
const dimensionValue = this.isVertical ? target.scrollHeight : target.scrollWidth
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.isVertical ? {} : { height: `${this.initialDimensions.height}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
34 changes: 18 additions & 16 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,
axis: 'vertical' | 'horizontal',
expandStyles: {},
collapseStyles: {},
collapsedHeight: 0,
collapsedDimension: 0,
easing: string,
duration: number,
onCollapseStart: func,
Expand All @@ -97,20 +98,21 @@ const { getCollapseProps, getToggleProps, isExpanded, setExpanded } =

The following are optional properties passed into `useCollapse({ })`:

| Prop | Type | Default | Description |
| -------------------- | -------- | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| isExpanded | boolean | `undefined` | If true, the Collapse is expanded |
| defaultExpanded | boolean | `false` | If true, the Collapse will be expanded when mounted |
| 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 |
| 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 |
| onCollapseEnd | function | no-op | Handler called when the collapse animation ends |
| onExpandStart | function | no-op | Handler called when the expand animation begins |
| onExpandEnd | function | no-op | Handler called when the expand animation ends |
| hasDisabledAnimation | boolean | false | If true, will disable the animation |
| Prop | Type | Default | Description |
| -------------------- | -------------------------- | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| isExpanded | boolean | `undefined` | If true, the Collapse is expanded |
| defaultExpanded | boolean | `false` | If true, the Collapse will be expanded when mounted |
| axis | "vertical" / "horizontal" | `"vertical"` | If "horizontal", 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 |
| 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 |
| onCollapseEnd | function | no-op | Handler called when the collapse animation ends |
| onExpandStart | function | no-op | Handler called when the expand animation begins |
| onExpandEnd | function | no-op | Handler called when the expand animation ends |
| hasDisabledAnimation | boolean | false | If true, will disable the animation |

### What you get

Expand Down
2 changes: 1 addition & 1 deletion packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@
"vite": "^3.2.4"
},
"dependencies": {
"@collapsed/core": "workspace:*",
"@collapsed/core": "4.0.0",
"tiny-warning": "^1.0.3"
},
"repository": {
Expand Down
13 changes: 13 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,19 @@ export const Controlled = () => {
)
}

export const HorizontalMode = () => {
const { getToggleProps, getCollapseProps, isExpanded } = useCollapse({
axis: 'horizontal',
})

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
2 changes: 1 addition & 1 deletion pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.