UNPKG

@reusable-ui/collapsible

Version:

A capability of UI to expand/reduce its size or toggle the visibility.

306 lines (305 loc) 12.1 kB
// 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