@reusable-ui/collapsible
Version:
A capability of UI to expand/reduce its size or toggle the visibility.
306 lines (305 loc) • 12.1 kB
JavaScript
// react:
import {
// hooks:
useState, useRef, useEffect, } from 'react';
// cssfn:
import {
// writes css in javascript:
rule, states, style, vars, iif, cssVars, usesSuffixedProps, overwriteProps, } from '@cssfn/core'; // writes css in javascript
// reusable-ui utilities:
import {
// hooks:
useEvent, useScheduleTriggerEvent, } from '@reusable-ui/hooks'; // react helper hooks
import { useSemantic, } from '@reusable-ui/semantics'; // a semantic management system for react web components
import {
// hooks:
usePropEnabled, usePropReadOnly, } from '@reusable-ui/accessibilities'; // an accessibility management system
import {
// hooks:
useAnimatingState, } from '@reusable-ui/animating-state'; // a hook for creating animating state
// reusable-ui features:
import {
// hooks:
usesAnimation, } from '@reusable-ui/animation'; // animation stuff of UI
const [collapsibleVars] = cssVars({ prefix: 'co', minify: false }); // shared variables: ensures the server-side & client-side have the same generated css variable names
{
const { animationRegistry: { registerAnim } } = usesAnimation();
registerAnim(collapsibleVars.anim);
}
// .expanded will be added after expanding-animation done:
const selectorIfExpanded = '.expanded';
// .expanding = styled expand, [open] = native expand:
const selectorIfExpanding = ':is(.expanding, [open]):not(.expanded)';
// .collapsing will be added after loosing expand and will be removed after collapsing-animation done:
const selectorIfCollapsing = '.collapsing';
// if all above are not set => collapsed:
const selectorIfCollapsed = ':not(:is(.expanded, .expanding, [open], .collapsing))';
export const ifExpanded = (styles) => rule(selectorIfExpanded, styles);
export const ifExpanding = (styles) => rule(selectorIfExpanding, styles);
export const ifCollapsing = (styles) => rule(selectorIfCollapsing, styles);
export const ifCollapsed = (styles) => rule(selectorIfCollapsed, styles);
export const ifExpand = (styles) => rule([selectorIfExpanding, selectorIfExpanded], styles);
export const ifCollapse = (styles) => rule([selectorIfCollapsing, selectorIfCollapsed], styles);
export const ifExpandCollapsing = (styles) => rule([selectorIfExpanding, selectorIfExpanded, selectorIfCollapsing], styles);
export const ifExpandingCollapse = (styles) => rule([selectorIfExpanding, selectorIfCollapsing, selectorIfCollapsed], styles);
/**
* Adds a capability of UI to expand/reduce its size or toggle the visibility.
* @param config A configuration of `collapsibleRule`.
* @returns A `CollapsibleStuff` represents a collapsible state.
*/
export const usesCollapsible = (config) => {
return {
collapsibleRule: () => style({
// config states:
...states([
iif(!!config, {
...ifExpand({
// overwrites propName = propName{'Expand'}:
...overwriteProps(config, usesSuffixedProps(config, 'expand')),
}),
...ifCollapse({
// overwrites propName = propName{'Collapse'}:
...overwriteProps(config, usesSuffixedProps(config, 'collapse')),
}),
}),
]),
// animation states:
...states([
ifExpanding({
...vars({
[collapsibleVars.anim]: config?.animExpand,
}),
}),
ifCollapsing({
...vars({
[collapsibleVars.anim]: config?.animCollapse,
}),
}),
]),
}),
collapsibleVars,
};
};
const collapsibleCtrls = [
'dialog',
'details',
];
export const useCollapsible = (props) => {
// fn props:
const propExpanded = props.expanded ?? false;
const { tag } = useSemantic(props);
// fn states:
/*
* state is expand/collapse based on [controllable expanded]
* [uncontrollable expanded] is not supported
*/
const expandedFn = propExpanded /*controllable*/;
// states:
const [expanded, setExpanded, animation, { handleAnimationStart, handleAnimationEnd, handleAnimationCancel }] = useAnimatingState({
initialState: expandedFn,
animationName: /((^|[^a-z])(expand|collapse)|([a-z])(Expand|Collapse))(?![a-z])/,
});
// update state:
const expandingTokenRef = useRef(0);
if (expanded !== expandedFn) { // change detected => apply the change & start animating
const expandingTokenLocal = (expandingTokenRef.current === Number.MAX_SAFE_INTEGER) ? 0 : (++expandingTokenRef.current);
expandingTokenRef.current = expandingTokenLocal;
if (expandedFn) {
// expanding:
/*
Make a delay time in order to React to completing the rendering <ChildComponent> (if `lazy=true` applied)
*/
Promise.resolve().then(() => {
if (expandingTokenRef.current === expandingTokenLocal) { // if token changed => abort to `setExpanded`
setExpanded(expandedFn); // remember the last change
} // if
});
}
else {
// collapsing:
/*
No need to make a delay time.
*/
setExpanded(expandedFn); // remember the last change
} // if
} // if
// fn props:
const state = (() => {
// expanding:
if (animation === true)
return 2 /* CollapsibleState.Expanding */;
// collapsing:
if (animation === false)
return 1 /* CollapsibleState.Collapsing */;
// fully expanded:
if (expanded)
return 3 /* CollapsibleState.Expanded */;
// fully collapsed:
return 0 /* CollapsibleState.Collapsed */;
})();
const stateClass = (() => {
switch (state) {
// expanding:
case 2 /* CollapsibleState.Expanding */:
{
// not [expanded] but *still* animating of expanding => force to keep expanding using class .expanding
if (!expanded)
return 'expanding';
if (tag && collapsibleCtrls.includes(tag))
return null; // uses [open]
return 'expanding';
}
;
// collapsing:
case 1 /* CollapsibleState.Collapsing */:
{
return 'collapsing';
}
;
// fully expanded:
case 3 /* CollapsibleState.Expanded */:
{
return 'expanded';
}
;
// fully collapsed:
case 0 /* CollapsibleState.Collapsed */:
{
return null;
}
;
} // switch
})();
// api:
return {
expanded: expanded,
isVisible: (propExpanded // sooner : The <ChildComponent> need to be rendered first BEFORE the expading animation OCCURED
// ||
// expanded // too late : The expading animation is running BEFORE the <ChildComponent> already rendered
||
(animation !== undefined) // being collapsing but not fully collapsed
),
state: state,
class: stateClass,
props: (() => {
if (!expanded)
return null;
// use [open] if <dialog> or <details>:
if (tag && collapsibleCtrls.includes(tag))
return { open: true };
// else, use .expanding or .expanded which already defined in `class`:
return null;
})(),
handleAnimationStart,
handleAnimationEnd,
handleAnimationCancel,
};
};
export const useCollapsibleEvent = (props, collapsibleApi) => {
// states:
const { state } = collapsibleApi;
// events:
const scheduleTriggerEvent = useScheduleTriggerEvent();
const { onExpandStart, onCollapseStart, onExpandEnd, onCollapseEnd, } = props;
const prevState = useRef(state);
useEffect(() => {
// conditions:
if (prevState.current === state)
return; // no change detected => ignore
prevState.current = state; // sync the last change
// actions:
switch (state) {
// expanding:
case 2 /* CollapsibleState.Expanding */:
if (onExpandStart)
scheduleTriggerEvent(() => {
// fire `onExpandStart` react event:
onExpandStart();
});
break;
// collapsing:
case 1 /* CollapsibleState.Collapsing */:
if (onCollapseStart)
scheduleTriggerEvent(() => {
// fire `onCollapseStart` react event:
onCollapseStart();
});
break;
// fully expanded:
case 3 /* CollapsibleState.Expanded */:
if (onExpandEnd)
scheduleTriggerEvent(() => {
// fire `onExpandEnd` react event:
onExpandEnd();
});
break;
// fully collapsed:
case 0 /* CollapsibleState.Collapsed */:
if (onCollapseEnd)
scheduleTriggerEvent(() => {
// fire `onCollapseEnd` react event:
onCollapseEnd();
});
break;
} // switch
}, [
state,
onExpandStart,
onCollapseStart,
onExpandEnd,
onCollapseEnd,
]);
};
export const useUncontrollableCollapsible = (props) => {
// accessibilities:
const propEnabled = usePropEnabled(props);
const propReadOnly = usePropReadOnly(props);
const isDisabledOrReadOnly = (!propEnabled || propReadOnly);
// states:
const [expandedTg, setExpandedTg] = useState(props.defaultExpanded ?? false);
/*
* state is expanded/collapsed based on [controllable expanded] (if set) and fallback to [uncontrollable expanded]
*/
const expandedFn = props.expanded /*controllable*/ ?? expandedTg /*uncontrollable*/;
// events:
const scheduleTriggerEvent = useScheduleTriggerEvent();
const { onExpandedChange, } = props;
/*
controllable : setExpanded(new) => update state(old => old) => trigger Event(new)
uncontrollable : setExpanded(new) => update state(old => new) => trigger Event(new)
*/
const triggerExpandedChange = useEvent((expanded) => {
if (onExpandedChange)
scheduleTriggerEvent(() => {
// fire `onExpandedChange` react event:
onExpandedChange({ expanded });
});
});
// callbacks:
const setExpanded = useEvent((expanded) => {
// conditions:
if (isDisabledOrReadOnly)
return; // control is disabled or readOnly => no response required
const newExpanded = (typeof (expanded) === 'function') ? expanded(expandedFn) : expanded;
if (newExpanded === expandedFn)
return; // still the same => nothing to update
// update:
setExpandedTg(newExpanded);
triggerExpandedChange(newExpanded);
}); // a stable callback, the `setExpanded` guaranteed to never change
const toggleExpanded = useEvent(() => {
// conditions:
if (isDisabledOrReadOnly)
return; // control is disabled or readOnly => no response required
const newExpanded = !expandedFn;
// update:
setExpandedTg(newExpanded);
triggerExpandedChange(newExpanded);
}); // a stable callback, the `toggleExpanded` guaranteed to never change
return [
expandedFn,
setExpanded,
toggleExpanded,
];
};
//#endregion collapsible