@design-edito/tools 0.5.3 → 0.5.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  export * as absoluteModulo from './absolute-modulo/index.js'
2
2
  export * as approximateRational from './approximate-rational/index.js'
3
3
  export * as clamp from './clamp/index.js'
4
- export * as interpolate from './interpolate/index.js'
5
4
  export * as geometricProgressions from './geometric-progressions/index.js'
5
+ export * as interpolate from './interpolate/index.js'
6
6
  export * as round from './round/index.js'
@@ -1,6 +1,6 @@
1
1
  export * as absoluteModulo from './absolute-modulo/index.js'
2
2
  export * as approximateRational from './approximate-rational/index.js'
3
3
  export * as clamp from './clamp/index.js'
4
- export * as interpolate from './interpolate/index.js'
5
4
  export * as geometricProgressions from './geometric-progressions/index.js'
5
+ export * as interpolate from './interpolate/index.js'
6
6
  export * as round from './round/index.js'
@@ -10,6 +10,8 @@ import type { WithClassName } from '../utils/types.js';
10
10
  * clipboard content is resolved, with the container's raw HTML.
11
11
  * @property onClipped - Called once content has been written to the clipboard.
12
12
  * Not called when the write fails.
13
+ * @property onCopyFailed - Called when the clipboard write throws, with the
14
+ * error. When set, the error is no longer logged to the console.
13
15
  * @property className - Additional class name(s) applied to the root element.
14
16
  * @property children - Content rendered inside the copyable container.
15
17
  */
@@ -17,6 +19,7 @@ export type Props = PropsWithChildren<WithClassName<{
17
19
  toClip?: string | ((curr: string | undefined) => string | undefined);
18
20
  onCopyClicked?: (e: MouseEvent<HTMLButtonElement, globalThis.MouseEvent>, rawContent: string | undefined) => void;
19
21
  onClipped?: (content: string) => void;
22
+ onCopyFailed?: (error: unknown) => void;
20
23
  }>>;
21
24
  /**
22
25
  * Clipboard-enabled container. Renders arbitrary content alongside a copy
@@ -34,7 +37,8 @@ export type Props = PropsWithChildren<WithClassName<{
34
37
  * @returns A copy-enabled content container.
35
38
  *
36
39
  * @remarks
37
- * A failed clipboard write is logged and leaves the component untouched:
38
- * neither `onClipped` nor the `clipped` modifier fires.
40
+ * A failed clipboard write fires `onCopyFailed` (or is logged when that prop is
41
+ * unset) and leaves the component untouched: neither `onClipped` nor the
42
+ * `clipped` modifier fires.
39
43
  */
40
44
  export declare const Clippable: FunctionComponent<Props>;
@@ -22,10 +22,11 @@ const clippedModifierDurationMs = 3000;
22
22
  * @returns A copy-enabled content container.
23
23
  *
24
24
  * @remarks
25
- * A failed clipboard write is logged and leaves the component untouched:
26
- * neither `onClipped` nor the `clipped` modifier fires.
25
+ * A failed clipboard write fires `onCopyFailed` (or is logged when that prop is
26
+ * unset) and leaves the component untouched: neither `onClipped` nor the
27
+ * `clipped` modifier fires.
27
28
  */
28
- export const Clippable = ({ className, children, toClip, onCopyClicked, onClipped }) => {
29
+ export const Clippable = ({ className, children, toClip, onCopyClicked, onClipped, onCopyFailed }) => {
29
30
  // State & refs
30
31
  const [hasBeenRecentlyClipped, setHasBeenRecentlyClipped] = useState(false);
31
32
  const contentRef = useRef(null);
@@ -50,8 +51,11 @@ export const Clippable = ({ className, children, toClip, onCopyClicked, onClippe
50
51
  ]);
51
52
  }
52
53
  catch (err) {
54
+ if (onCopyFailed !== undefined)
55
+ onCopyFailed(err);
53
56
  // eslint-disable-next-line no-console
54
- console.error(err);
57
+ else
58
+ console.error(err);
55
59
  return;
56
60
  }
57
61
  onClipped?.(html);
@@ -3,7 +3,9 @@ import type { WithClassName } from '../utils/types.js';
3
3
  /**
4
4
  * Props for the {@link Disclaimer} component.
5
5
  *
6
- * @property content - Content displayed inside the disclaimer panel.
6
+ * @property children - The content the disclaimer gates, rendered below the panel.
7
+ * @property undisclosedContent - Content displayed inside the disclaimer panel,
8
+ * until it is dismissed.
7
9
  * @property togglerContent - Content rendered inside the dismiss toggler.
8
10
  * When omitted, the toggler is not rendered.
9
11
  * @property isOn - Controlled visibility state. When defined, the component
@@ -15,10 +17,9 @@ import type { WithClassName } from '../utils/types.js';
15
17
  * @property onIsOnChanged - Called after the visibility state changed, with the
16
18
  * new value.
17
19
  * @property className - Optional additional class name(s) applied to the root element.
18
- * @property children - The content the disclaimer gates, rendered below the panel.
19
20
  */
20
21
  export type Props = PropsWithChildren<WithClassName<{
21
- content?: ReactNode;
22
+ undisclosedContent?: ReactNode;
22
23
  togglerContent?: ReactNode;
23
24
  isOn?: boolean;
24
25
  defaultIsOn?: boolean;
@@ -34,7 +35,7 @@ export type Props = PropsWithChildren<WithClassName<{
34
35
  *
35
36
  * ### CSS elements
36
37
  * - `panel`
37
- * - `content`
38
+ * - `content` — wraps `undisclosedContent`.
38
39
  * - `toggler`
39
40
  * - `sensitive` — wraps `children`.
40
41
  *
@@ -13,7 +13,7 @@ import cssModule from './styles.module.css';
13
13
  *
14
14
  * ### CSS elements
15
15
  * - `panel`
16
- * - `content`
16
+ * - `content` — wraps `undisclosedContent`.
17
17
  * - `toggler`
18
18
  * - `sensitive` — wraps `children`.
19
19
  *
@@ -28,7 +28,7 @@ import cssModule from './styles.module.css';
28
28
  * know a click happened at all.
29
29
  * - `onIsOnChanged` fires in both modes too, and never on mount.
30
30
  */
31
- export const Disclaimer = ({ content, togglerContent, isOn: isOnProp, defaultIsOn = true, onDismissClicked, onIsOnChanged, children, className }) => {
31
+ export const Disclaimer = ({ children, undisclosedContent, togglerContent, isOn: isOnProp, defaultIsOn = true, onDismissClicked, onIsOnChanged, className }) => {
32
32
  // State
33
33
  const [internalIsOn, setInternalIsOn] = useState(defaultIsOn);
34
34
  const isControlled = isOnProp !== undefined;
@@ -52,5 +52,5 @@ export const Disclaimer = ({ content, togglerContent, isOn: isOnProp, defaultIsO
52
52
  const contentClss = c('content');
53
53
  const togglerClss = c('toggler');
54
54
  const sensitiveClss = c('sensitive');
55
- return _jsxs("div", { className: rootClss, children: [_jsxs("div", { className: panelClss, children: [content !== undefined && _jsx("div", { className: contentClss, children: content }), togglerContent !== undefined && _jsx("div", { className: togglerClss, onClick: handleDismissClick, children: togglerContent })] }), _jsx("div", { className: sensitiveClss, children: children })] });
55
+ return _jsxs("div", { className: rootClss, children: [_jsxs("div", { className: panelClss, children: [undisclosedContent !== undefined && _jsx("div", { className: contentClss, children: undisclosedContent }), togglerContent !== undefined && _jsx("div", { className: togglerClss, onClick: handleDismissClick, children: togglerContent })] }), _jsx("div", { className: sensitiveClss, children: children })] });
56
56
  };
@@ -15,6 +15,8 @@ import type { WithClassName } from '../utils/types.js';
15
15
  * drawer reacts, with the open state as it was.
16
16
  * @property onIsOpenedChanged - Called after the open state changed, with the
17
17
  * new value.
18
+ * @property onContentResized - Called after the measured content size changed,
19
+ * with the new width and height in pixels. Never on mount.
18
20
  * @property className - Additional class name(s) applied to the root element.
19
21
  * @property children - Drawer content.
20
22
  */
@@ -26,6 +28,10 @@ export type Props = PropsWithChildren<WithClassName<{
26
28
  onOpenerClicked?: (isOpened: boolean) => void;
27
29
  onCloserClicked?: (isOpened: boolean) => void;
28
30
  onIsOpenedChanged?: (isOpened: boolean) => void;
31
+ onContentResized?: (dimensions: {
32
+ width: number;
33
+ height: number;
34
+ }) => void;
29
35
  }>>;
30
36
  /**
31
37
  * Drawer component supporting controlled and uncontrolled usage.
@@ -40,7 +40,7 @@ import cssModule from './styles.module.css';
40
40
  * parent needs them to know a click happened at all.
41
41
  * - `onIsOpenedChanged` fires in both modes too, and never on mount.
42
42
  */
43
- export const Drawer = ({ openerContent, closerContent, defaultIsOpened = false, isOpened: isOpenedProp, onOpenerClicked, onCloserClicked, onIsOpenedChanged, className, children }) => {
43
+ export const Drawer = ({ openerContent, closerContent, defaultIsOpened = false, isOpened: isOpenedProp, onOpenerClicked, onCloserClicked, onIsOpenedChanged, onContentResized, className, children }) => {
44
44
  // State
45
45
  const [internalIsOpened, setInternalIsOpened] = useState(defaultIsOpened);
46
46
  const [contentDimensions, setContentDimensions] = useState();
@@ -48,6 +48,8 @@ export const Drawer = ({ openerContent, closerContent, defaultIsOpened = false,
48
48
  const isOpened = isOpenedProp ?? internalIsOpened;
49
49
  // State dispatch
50
50
  useChangeDispatch(isOpened, onIsOpenedChanged);
51
+ useChangeDispatch(contentDimensions, dimensions => { if (dimensions !== undefined)
52
+ onContentResized?.(dimensions); }, (a, b) => a?.width === b?.width && a?.height === b?.height);
51
53
  // User action handlers
52
54
  const handleOpenerClick = () => {
53
55
  onOpenerClicked?.(isOpened);
@@ -31,6 +31,10 @@ import type { WithClassName } from '../utils/types.js';
31
31
  * before the gallery reacts, with the active index as it was and the target index.
32
32
  * @property onActiveSlotChanged - Called after the active slot changed, with
33
33
  * the new index.
34
+ * @property onCanGoLeftChanged - Called after the ability to scroll further left
35
+ * changed, with the new value. Never on mount.
36
+ * @property onCanGoRightChanged - Called after the ability to scroll further
37
+ * right changed, with the new value. Never on mount.
34
38
  * @property className - Optional additional class name(s) applied to the root element.
35
39
  * @property children - Elements rendered as gallery slots. Each child is wrapped in a slot container.
36
40
  */
@@ -48,6 +52,8 @@ export type Props = PropsWithChildren<WithClassName<{
48
52
  onNextClicked?: (activePos: number) => void;
49
53
  onPaginationClicked?: (activePos: number, targetPos: number) => void;
50
54
  onActiveSlotChanged?: (activePos: number) => void;
55
+ onCanGoLeftChanged?: (canGoLeft: boolean) => void;
56
+ onCanGoRightChanged?: (canGoRight: boolean) => void;
51
57
  }>>;
52
58
  /**
53
59
  * Horizontally scrollable gallery component with navigation controls and pagination.
@@ -23,7 +23,7 @@ function resolvePadding(side, shorthand) {
23
23
  * - Previous/next navigation controls,
24
24
  * - Pagination controls allowing direct slot activation.
25
25
  */
26
- export const Gallery = ({ paddingLeft, paddingRight, padding, prevButtonContent, nextButtonContent, paginationContent, defaultActive, active, noSnap, onPrevClicked, onNextClicked, onPaginationClicked, onActiveSlotChanged, children, className }) => {
26
+ export const Gallery = ({ paddingLeft, paddingRight, padding, prevButtonContent, nextButtonContent, paginationContent, defaultActive, active, noSnap, onPrevClicked, onNextClicked, onPaginationClicked, onActiveSlotChanged, onCanGoLeftChanged, onCanGoRightChanged, children, className }) => {
27
27
  // State & refs
28
28
  const scrollerRef = useRef(null);
29
29
  const [activeIndex, setActiveIndex] = useState(0);
@@ -33,6 +33,8 @@ export const Gallery = ({ paddingLeft, paddingRight, padding, prevButtonContent,
33
33
  const isControlled = active !== undefined;
34
34
  // State dispatch
35
35
  useChangeDispatch(activeIndex, onActiveSlotChanged);
36
+ useChangeDispatch(canGoLeft, onCanGoLeftChanged);
37
+ useChangeDispatch(canGoRight, onCanGoRightChanged);
36
38
  // User actions handlers
37
39
  const handlePrevClick = () => {
38
40
  onPrevClicked?.(activeIndex);
@@ -1,5 +1,4 @@
1
1
  import { type FunctionComponent, type ImgHTMLAttributes } from 'react';
2
- import { type Props as TheatreProps } from '../Theatre/index.js';
3
2
  import type { WithClassName } from '../utils/types.js';
4
3
  /**
5
4
  * Describes a single responsive image source for use in a `<picture>` element.
@@ -27,16 +26,14 @@ type SourceData = {
27
26
  * - a single srcSet string,
28
27
  * - an array of srcSet strings,
29
28
  * - an array of {@link SourceData} objects for full `<source>` control.
30
- * @property theatre - Props forwarded to the internal {@link Theatre} component.
31
29
  * @property className - Optional additional class name(s) applied to the root element.
32
30
  */
33
31
  export type Props = WithClassName<{
34
32
  sources?: string | string[] | SourceData[];
35
- theatre?: TheatreProps;
36
33
  }> & ImgHTMLAttributes<HTMLImageElement>;
37
34
  /**
38
35
  * Image component. Wraps a native `<img>` (or `<picture>`) element with
39
- * optional responsive sources and an optional theatre mode.
36
+ * optional responsive sources.
40
37
  *
41
38
  * ### CSS elements
42
39
  * - `picture` — wrapping `<picture>` element, always rendered. Contains the
@@ -1,13 +1,12 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useMemo } from 'react';
3
3
  import { clss } from '../../agnostic/css/clss/index.js';
4
- import { Theatre } from '../Theatre/index.js';
5
4
  import { mergeClassNames } from '../utils/index.js';
6
5
  import { image as publicClassName } from '../public-classnames.js';
7
6
  import cssModule from './styles.module.css';
8
7
  /**
9
8
  * Image component. Wraps a native `<img>` (or `<picture>`) element with
10
- * optional responsive sources and an optional theatre mode.
9
+ * optional responsive sources.
11
10
  *
12
11
  * ### CSS elements
13
12
  * - `picture` — wrapping `<picture>` element, always rendered. Contains the
@@ -18,7 +17,7 @@ import cssModule from './styles.module.css';
18
17
  * @see {@link Props}
19
18
  * @returns A `<figure>` element containing a `<picture>` with the image.
20
19
  */
21
- export const Image = ({ sources, theatre, className, ...intrinsicImgAttributes }) => {
20
+ export const Image = ({ sources, className, ...intrinsicImgAttributes }) => {
22
21
  // State
23
22
  const parsedSources = useMemo(() => {
24
23
  if (sources === undefined)
@@ -42,8 +41,5 @@ export const Image = ({ sources, theatre, className, ...intrinsicImgAttributes }
42
41
  const pictureClss = c('picture');
43
42
  const imgClss = c('image');
44
43
  const pictureContent = _jsxs("picture", { className: pictureClss, children: [parsedSources.map((source, index) => _jsx("source", { srcSet: typeof source === 'string' ? source : source.srcSet, type: typeof source === 'string' ? undefined : source.type, media: typeof source === 'string' ? undefined : source.media, sizes: typeof source === 'string' ? undefined : source.sizes }, index)), _jsx("img", { className: imgClss, ...intrinsicImgAttributes })] });
45
- const theatricalContent = theatre !== undefined
46
- ? _jsx(Theatre, { defaultIsOn: false, ...theatre, children: pictureContent })
47
- : pictureContent;
48
- return _jsx("figure", { className: rootClss, children: theatricalContent });
44
+ return _jsx("figure", { className: rootClss, children: pictureContent });
49
45
  };
@@ -3,7 +3,7 @@ import type { WithClassName } from '../utils/types.js';
3
3
  /**
4
4
  * Describes a single overlay positioned over the base content.
5
5
  *
6
- * @property content - React node rendered inside the overlay element.
6
+ * @property children - React node rendered inside the overlay element.
7
7
  * If falsy, the overlay is not rendered.
8
8
  * @property xPercent - Horizontal position of the overlay anchor as a percentage
9
9
  * of the root element's width. Defaults to `0`.
@@ -17,7 +17,7 @@ import type { WithClassName } from '../utils/types.js';
17
17
  * - `number` — arbitrary percentage offset (e.g. `25` produces `-25%`).
18
18
  */
19
19
  type Overlay = {
20
- content?: ReactNode;
20
+ children?: ReactNode;
21
21
  xPercent?: number;
22
22
  yPercent?: number;
23
23
  justify?: 'left' | 'center' | 'right' | number;
@@ -26,7 +26,7 @@ type Overlay = {
26
26
  * Props for the {@link Overlayer} component.
27
27
  *
28
28
  * @property overlays - Array of {@link Overlay} descriptors rendered on top of
29
- * the base content. Overlays with falsy `content` are skipped.
29
+ * the base content. Overlays with falsy `children` are skipped.
30
30
  * @property className - Optional additional class name(s) applied to the root element.
31
31
  * @property children - Content rendered in the base layer, below all overlays.
32
32
  */
@@ -40,7 +40,7 @@ export type Props = PropsWithChildren<WithClassName<{
40
40
  *
41
41
  * ### Child elements
42
42
  * - `__base` — wrapping `<div>` that contains `children`.
43
- * - `__overlay` — one `<div>` per entry in `overlays` (falsy `content` entries
43
+ * - `__overlay` — one `<div>` per entry in `overlays` (falsy `children` entries
44
44
  * are omitted).
45
45
  *
46
46
  * ### CSS custom properties on each overlay element
@@ -11,7 +11,7 @@ import cssModule from './styles.module.css';
11
11
  *
12
12
  * ### Child elements
13
13
  * - `__base` — wrapping `<div>` that contains `children`.
14
- * - `__overlay` — one `<div>` per entry in `overlays` (falsy `content` entries
14
+ * - `__overlay` — one `<div>` per entry in `overlays` (falsy `children` entries
15
15
  * are omitted).
16
16
  *
17
17
  * ### CSS custom properties on each overlay element
@@ -30,7 +30,7 @@ export const Overlayer = ({ overlays, children, className }) => {
30
30
  const c = clss(publicClassName, { cssModule });
31
31
  const rootClss = mergeClassNames(c(), className);
32
32
  const baseClss = c('base');
33
- return _jsxs("div", { className: rootClss, children: [_jsx("div", { className: baseClss, children: children }), overlays?.map(({ content, xPercent = 0, yPercent = 0, justify }, overlayPos) => {
33
+ return _jsxs("div", { className: rootClss, children: [_jsx("div", { className: baseClss, children: children }), overlays?.map(({ children: overlayChildren, xPercent = 0, yPercent = 0, justify }, overlayPos) => {
34
34
  const overlayClss = c('overlay');
35
35
  let computedTranslateX;
36
36
  if (typeof justify === 'number') {
@@ -53,8 +53,8 @@ export const Overlayer = ({ overlays, children, className }) => {
53
53
  '--PRIVATE-top': `${yPercent}%`,
54
54
  '--PRIVATE-translate-x': computedTranslateX
55
55
  };
56
- if (isFalsy(content))
56
+ if (isFalsy(overlayChildren))
57
57
  return null;
58
- return _jsx("div", { className: overlayClss, style: overlayCustomProps, children: content }, overlayPos);
58
+ return _jsx("div", { className: overlayClss, style: overlayCustomProps, children: overlayChildren }, overlayPos);
59
59
  })] });
60
60
  };
@@ -74,6 +74,8 @@ export type PropsPage = {
74
74
  * @property onPageChanged - Called once the current page has changed, never on
75
75
  * mount. Receives the zero-based index of the new current page and the
76
76
  * corresponding page definition, if available.
77
+ * @property onContentVisibilityChanged - Called after the scrolling content
78
+ * area entered or left the viewport, with the new value. Never on mount.
77
79
  * @property className - Optional additional class name(s) applied to the root
78
80
  * element.
79
81
  */
@@ -83,6 +85,7 @@ export type Props = WithClassName<{
83
85
  stickyBlocksLazyLoadDistance?: number;
84
86
  forceStickBlocks?: 'before' | 'after' | 'both' | 'none';
85
87
  onPageChanged?: (currentPagePos: number, pageData?: PropsPage) => void;
88
+ onContentVisibilityChanged?: (isVisible: boolean) => void;
86
89
  }>;
87
90
  /**
88
91
  * Scrollytelling engine component. Orchestrates layered sticky blocks (`back`
@@ -56,7 +56,7 @@ import cssModule from './styles.module.css';
56
56
  * back-blocks layer, front-blocks layer, paginated scrolling content, and
57
57
  * bottom-bound sentinel.
58
58
  */
59
- export const Scrllgngn = ({ pages, thresholdOffsetPercent, stickyBlocksLazyLoadDistance = 2, forceStickBlocks, onPageChanged, className }) => {
59
+ export const Scrllgngn = ({ pages, thresholdOffsetPercent, stickyBlocksLazyLoadDistance = 2, forceStickBlocks, onPageChanged, onContentVisibilityChanged, className }) => {
60
60
  // State
61
61
  const [topVisible, setTopVis] = useState(false);
62
62
  const [contentVisible, setCntVis] = useState(false);
@@ -72,6 +72,7 @@ export const Scrllgngn = ({ pages, thresholdOffsetPercent, stickyBlocksLazyLoadD
72
72
  const lazyLoadedFrontBlocks = lazyLoadedBlocks(stickyBlocks, 'front', currentPagePos, stickyBlocksLazyLoadDistance);
73
73
  // Handlers
74
74
  useChangeDispatch(currentPagePos, pagePos => onPageChanged?.(pagePos, pages?.[pagePos]));
75
+ useChangeDispatch(contentVisible, onContentVisibilityChanged);
75
76
  const handleTopBoundDetect = e => setTopVis(e.ioEntry?.isIntersecting ?? false);
76
77
  const handleCntDetect = e => setCntVis(e.ioEntry?.isIntersecting ?? false);
77
78
  const handleBtmBoundDetect = e => setBtmVis(e.ioEntry?.isIntersecting ?? false);
@@ -12,6 +12,11 @@ import { type ScrollState } from './utils.js';
12
12
  * changed. Receives `undefined` until the first measurement lands.
13
13
  * @property onVisibilityChanged - Called on every intersection change, with
14
14
  * `true` when the component intersects the viewport.
15
+ * @property onScrollProgressChanged - Called after the element's vertical outer
16
+ * scroll progress changed: `0` when it is about to enter the viewport, `1` once
17
+ * it has fully left it. Never on mount.
18
+ * @property onScrollDirectionChanged - Called after the document scroll
19
+ * direction changed, with `'up'` or `'down'`. Never on mount.
15
20
  * @property className - Optional additional class name(s) applied to the root element.
16
21
  * @property children - React nodes rendered inside the scroll listener container.
17
22
  */
@@ -20,6 +25,8 @@ export type Props = PropsWithChildren<WithClassName<{
20
25
  stopOnHidden?: boolean;
21
26
  onScrollStateChanged?: (scrollState?: ScrollState) => void;
22
27
  onVisibilityChanged?: (isVisible: boolean) => void;
28
+ onScrollProgressChanged?: (progress: number) => void;
29
+ onScrollDirectionChanged?: (direction: 'up' | 'down') => void;
23
30
  }>>;
24
31
  /**
25
32
  * Exposes scroll metrics — both the document's and its own — as CSS custom
@@ -5,7 +5,7 @@ import { randomHash } from '../../agnostic/random/uuid/index.js';
5
5
  import { IntersectionObserverComponent } from '../IntersectionObserver/index.js';
6
6
  import { mergeClassNames, useChangeDispatch } from '../utils/index.js';
7
7
  import { scrollListener as publicClassName } from '../public-classnames.js';
8
- import { subscribe, toScrollCssProps, unsubscribe } from './utils.js';
8
+ import { getScrollProgress, subscribe, toScrollCssProps, unsubscribe } from './utils.js';
9
9
  import cssModule from './styles.module.css';
10
10
  /**
11
11
  * Exposes scroll metrics — both the document's and its own — as CSS custom
@@ -45,13 +45,33 @@ import cssModule from './styles.module.css';
45
45
  * for everyone, each element only for itself. The listeners exist only while at
46
46
  * least one instance is tracking.
47
47
  */
48
- export const ScrollListener = ({ startOnVisible, stopOnHidden, onScrollStateChanged, onVisibilityChanged, className, children }) => {
48
+ export const ScrollListener = ({ startOnVisible, stopOnHidden, onScrollStateChanged, onVisibilityChanged, onScrollProgressChanged, onScrollDirectionChanged, className, children }) => {
49
49
  // State & refs
50
50
  const [subscriberId] = useState(() => randomHash(6));
51
51
  const [scrollState, setScrollState] = useState();
52
+ const [scrollDirection, setScrollDirection] = useState(null);
52
53
  const rootRef = useRef(null);
54
+ const previousScrollYRef = useRef(null);
55
+ const scrollProgress = scrollState === undefined
56
+ ? undefined
57
+ : getScrollProgress(scrollState).y;
53
58
  // State dispatch
54
59
  useChangeDispatch(scrollState, onScrollStateChanged);
60
+ useChangeDispatch(scrollProgress, progress => { if (progress !== undefined)
61
+ onScrollProgressChanged?.(progress); });
62
+ useChangeDispatch(scrollDirection, direction => { if (direction !== null)
63
+ onScrollDirectionChanged?.(direction); });
64
+ // Fx. dep. `scrollState` - derive the document scroll direction
65
+ useEffect(() => {
66
+ if (scrollState === undefined)
67
+ return;
68
+ const y = scrollState.global.scroll.y;
69
+ const previous = previousScrollYRef.current;
70
+ previousScrollYRef.current = y;
71
+ if (previous === null || y === previous)
72
+ return;
73
+ setScrollDirection(y > previous ? 'down' : 'up');
74
+ }, [scrollState]);
55
75
  // Fx. no dep. - track from mount, unless waiting for the component to show up.
56
76
  // The cleanup runs whichever way the subscription was opened.
57
77
  useEffect(() => {
@@ -79,6 +79,14 @@ export declare function subscribe(id: string, subscriber: Subscriber): void;
79
79
  * @param id - The id passed to {@link subscribe}.
80
80
  */
81
81
  export declare function unsubscribe(id: string): void;
82
+ /**
83
+ * The element's outer scroll progress on each axis — `0` when it is about to
84
+ * enter the viewport, `1` once it has fully left it.
85
+ */
86
+ export declare function getScrollProgress(scrollState: ScrollState): {
87
+ x: number;
88
+ y: number;
89
+ };
82
90
  /**
83
91
  * Builds the CSS custom properties exposed on a {@link ScrollListener} root.
84
92
  *
@@ -116,6 +116,17 @@ function outerRange(offset, size, winSize) {
116
116
  function progressIn(scroll, [from, to]) {
117
117
  return (scroll - from) / Math.max(to - from, 1);
118
118
  }
119
+ /**
120
+ * The element's outer scroll progress on each axis — `0` when it is about to
121
+ * enter the viewport, `1` once it has fully left it.
122
+ */
123
+ export function getScrollProgress(scrollState) {
124
+ const { global: { win, scroll }, local } = scrollState;
125
+ return {
126
+ x: progressIn(scroll.x, outerRange(local.offsetX, local.width, win.width)),
127
+ y: progressIn(scroll.y, outerRange(local.offsetY, local.height, win.height))
128
+ };
129
+ }
119
130
  /**
120
131
  * Builds the CSS custom properties exposed on a {@link ScrollListener} root.
121
132
  *
@@ -36,6 +36,11 @@ import { type Props as ControlledProps } from './index.controlled.js';
36
36
  * with the new value.
37
37
  * @property onStepChanged - Called after the forwarded step changed, with the
38
38
  * new value.
39
+ * @property onLooped - Called when the step wraps around (either direction),
40
+ * only while `loop` is `true`.
41
+ * @property onReachedFirstStep - Called when the forwarded step becomes `0`.
42
+ * @property onReachedLastStep - Called when the forwarded step becomes the last
43
+ * one.
39
44
  */
40
45
  export type Props = Omit<ControlledProps, 'isPlaying' | 'tempo'> & {
41
46
  defaultStep?: number;
@@ -51,6 +56,9 @@ export type Props = Omit<ControlledProps, 'isPlaying' | 'tempo'> & {
51
56
  onIntersected?: IOCompProps['onIntersected'];
52
57
  onIsPlayingChanged?: (isPlaying: boolean) => void;
53
58
  onStepChanged?: (step: number) => void;
59
+ onLooped?: () => void;
60
+ onReachedFirstStep?: () => void;
61
+ onReachedLastStep?: () => void;
54
62
  };
55
63
  /**
56
64
  * Uncontrolled, self-advancing sequencer component. Drives a
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { useState, useEffect, useCallback, Children } from 'react';
2
+ import { useState, useEffect, useCallback, useRef, Children } from 'react';
3
3
  import { absoluteModulo } from '../../agnostic/numbers/absolute-modulo/index.js';
4
4
  import { clamp } from '../../agnostic/numbers/clamp/index.js';
5
5
  import { IntersectionObserverComponent } from '../IntersectionObserver/index.js';
@@ -30,7 +30,7 @@ import { ControlledSequencer } from './index.controlled.js';
30
30
  * @returns An {@link IntersectionObserverComponent} wrapping a
31
31
  * {@link ControlledSequencer} with the computed step and modifiers applied.
32
32
  */
33
- export const Sequencer = ({ defaultStep, tempo = 60, play, loop, clampFirst, clampLast, resetOnVisible, resetOnHidden, playOnVisible, pauseOnHidden, onIntersected, onIsPlayingChanged, onStepChanged, ...controlledProps }) => {
33
+ export const Sequencer = ({ defaultStep, tempo = 60, play, loop, clampFirst, clampLast, resetOnVisible, resetOnHidden, playOnVisible, pauseOnHidden, onIntersected, onIsPlayingChanged, onStepChanged, onLooped, onReachedFirstStep, onReachedLastStep, ...controlledProps }) => {
34
34
  // State
35
35
  const { step, activateOnStep, children } = controlledProps;
36
36
  const [internalPlay, setInternalPlay] = useState(play ?? false);
@@ -63,6 +63,23 @@ export const Sequencer = ({ defaultStep, tempo = 60, play, loop, clampFirst, cla
63
63
  // State dispatch
64
64
  useChangeDispatch(actualPlay, onIsPlayingChanged);
65
65
  useChangeDispatch(forwardedStep, onStepChanged);
66
+ // Fx. dep. `forwardedStep` - loop / boundary events
67
+ const previousStepsRef = useRef(null);
68
+ useEffect(() => {
69
+ if (stepsCount <= 0)
70
+ return;
71
+ const previous = previousStepsRef.current;
72
+ previousStepsRef.current = { forwarded: forwardedStep, actual: actualStep };
73
+ if (previous === null)
74
+ return;
75
+ const lapChanged = Math.floor(actualStep / stepsCount) !== Math.floor(previous.actual / stepsCount);
76
+ if (loop === true && lapChanged)
77
+ onLooped?.();
78
+ if (forwardedStep === 0 && previous.forwarded !== 0)
79
+ onReachedFirstStep?.();
80
+ if (forwardedStep === stepsCount - 1 && previous.forwarded !== stepsCount - 1)
81
+ onReachedLastStep?.();
82
+ }, [forwardedStep, actualStep, stepsCount, loop]);
66
83
  // Action handlers
67
84
  const handleIntersection = useCallback(({ ioEntry, observer }) => {
68
85
  onIntersected?.({ ioEntry, observer });
@@ -13,9 +13,16 @@ import type { WithClassName } from '../utils/types.js';
13
13
  * open counts as a toggle.
14
14
  * @property exitOnBgClick - When `true`, clicking the stage background — and
15
15
  * not its content — counts as a toggle.
16
- * @property onToggleClicked - Called whenever a toggle is requested: the open
17
- * or close button, the `Escape` key, or the stage background. Fires before the
18
- * theatre reacts, with the state as it was.
16
+ * @property onOpenButtonClicked - Called when the open button is clicked, before
17
+ * the theatre reacts, with the state as it was.
18
+ * @property onCloseButtonClicked - Called when the close button is clicked,
19
+ * before the theatre reacts, with the state as it was.
20
+ * @property onBackgroundClicked - Called when the stage background is clicked
21
+ * (only while `exitOnBgClick` is `true`), before the theatre reacts, with the
22
+ * state as it was.
23
+ * @property onEscapePressed - Called when `Escape` is pressed while the stage is
24
+ * open (only while `exitOnEscape` is `true`), before the theatre reacts, with
25
+ * the state as it was.
19
26
  * @property onIsOnChanged - Called after the theatre mode changed, with the new
20
27
  * value.
21
28
  * @property className - Optional additional class name(s) applied to the root element.
@@ -29,7 +36,10 @@ export type Props = PropsWithChildren<WithClassName<{
29
36
  defaultIsOn?: boolean;
30
37
  exitOnEscape?: boolean;
31
38
  exitOnBgClick?: boolean;
32
- onToggleClicked?: (isOn: boolean) => void;
39
+ onOpenButtonClicked?: (isOn: boolean) => void;
40
+ onCloseButtonClicked?: (isOn: boolean) => void;
41
+ onBackgroundClicked?: (isOn: boolean) => void;
42
+ onEscapePressed?: (isOn: boolean) => void;
33
43
  onIsOnChanged?: (isOn: boolean) => void;
34
44
  }>>;
35
45
  /**
@@ -53,8 +63,8 @@ export type Props = PropsWithChildren<WithClassName<{
53
63
  * @remarks
54
64
  * - In controlled mode (`isOn` defined), the state is fully driven by the
55
65
  * parent and internal state is never updated.
56
- * - `onToggleClicked` fires in both modes, for all four toggle sources — a
57
- * controlled parent needs it to know a toggle was requested at all.
66
+ * - The four toggle-source handlers fire in both modes a controlled parent
67
+ * needs them to know a toggle was requested at all.
58
68
  * - `onIsOnChanged` fires in both modes too, and never on mount.
59
69
  */
60
70
  export declare const Theatre: FunctionComponent<Props>;
@@ -25,11 +25,11 @@ import cssModule from './styles.module.css';
25
25
  * @remarks
26
26
  * - In controlled mode (`isOn` defined), the state is fully driven by the
27
27
  * parent and internal state is never updated.
28
- * - `onToggleClicked` fires in both modes, for all four toggle sources — a
29
- * controlled parent needs it to know a toggle was requested at all.
28
+ * - The four toggle-source handlers fire in both modes a controlled parent
29
+ * needs them to know a toggle was requested at all.
30
30
  * - `onIsOnChanged` fires in both modes too, and never on mount.
31
31
  */
32
- export const Theatre = ({ closeBtnContent, openBtnContent, isOn: isOnProp, defaultIsOn = false, exitOnEscape, exitOnBgClick, onToggleClicked, onIsOnChanged, children, className }) => {
32
+ export const Theatre = ({ closeBtnContent, openBtnContent, isOn: isOnProp, defaultIsOn = false, exitOnEscape, exitOnBgClick, onOpenButtonClicked, onCloseButtonClicked, onBackgroundClicked, onEscapePressed, onIsOnChanged, children, className }) => {
33
33
  // State & refs
34
34
  const [internalIsOn, setInternalIsOn] = useState(defaultIsOn);
35
35
  const stageRef = useRef(null);
@@ -39,18 +39,24 @@ export const Theatre = ({ closeBtnContent, openBtnContent, isOn: isOnProp, defau
39
39
  useChangeDispatch(isOn, onIsOnChanged);
40
40
  // User action handlers
41
41
  const requestToggle = (targetIsOn) => {
42
- onToggleClicked?.(isOn);
43
42
  if (isControlled)
44
43
  return;
45
44
  setInternalIsOn(targetIsOn);
46
45
  };
47
- const handleOpenBtnClick = () => requestToggle(true);
48
- const handleCloseBtnClick = () => requestToggle(false);
46
+ const handleOpenButtonClick = () => {
47
+ onOpenButtonClicked?.(isOn);
48
+ requestToggle(true);
49
+ };
50
+ const handleCloseButtonClick = () => {
51
+ onCloseButtonClicked?.(isOn);
52
+ requestToggle(false);
53
+ };
49
54
  const handleStageBgClick = e => {
50
55
  if (exitOnBgClick !== true)
51
56
  return;
52
57
  if (e.target !== stageRef.current)
53
58
  return;
59
+ onBackgroundClicked?.(isOn);
54
60
  requestToggle(false);
55
61
  };
56
62
  // Fx. dep. `exitOnEscape`, `isOn` - close the stage on the Escape key
@@ -60,11 +66,12 @@ export const Theatre = ({ closeBtnContent, openBtnContent, isOn: isOnProp, defau
60
66
  const handleKeyDown = (e) => {
61
67
  if (e.key !== 'Escape')
62
68
  return;
69
+ onEscapePressed?.(isOn);
63
70
  requestToggle(false);
64
71
  };
65
72
  window.addEventListener('keydown', handleKeyDown);
66
73
  return () => window.removeEventListener('keydown', handleKeyDown);
67
- }, [exitOnEscape, isOn, isControlled]);
74
+ }, [exitOnEscape, isOn, isControlled, onEscapePressed]);
68
75
  // Rendering
69
76
  const c = clss(publicClassName, { cssModule });
70
77
  const rootClss = mergeClassNames(c(null, {
@@ -74,5 +81,5 @@ export const Theatre = ({ closeBtnContent, openBtnContent, isOn: isOnProp, defau
74
81
  const stageClss = c('stage');
75
82
  const openBtnClss = c('open-btn');
76
83
  const closeBtnClss = c('close-btn');
77
- return _jsxs("div", { className: rootClss, children: [children, _jsx("div", { className: stageClss, onClick: handleStageBgClick, ref: stageRef, children: isOn && children }), _jsx("div", { className: closeBtnClss, onClick: handleCloseBtnClick, children: closeBtnContent }), _jsx("div", { className: openBtnClss, onClick: handleOpenBtnClick, children: openBtnContent })] });
84
+ return _jsxs("div", { className: rootClss, children: [children, _jsx("div", { className: stageClss, onClick: handleStageBgClick, ref: stageRef, children: isOn && children }), _jsx("div", { className: closeBtnClss, onClick: handleCloseButtonClick, children: closeBtnContent }), _jsx("div", { className: openBtnClss, onClick: handleOpenButtonClick, children: openBtnContent })] });
78
85
  };
@@ -3,8 +3,8 @@ export * as brighten from './brighten/index.js'
3
3
  export * as extend from './extend/index.js'
4
4
  export * as extract from './extract/index.js'
5
5
  export * as flatten from './flatten/index.js'
6
- export * as flip from './flip/index.js'
7
6
  export * as flop from './flop/index.js'
7
+ export * as flip from './flip/index.js'
8
8
  export * as hue from './hue/index.js'
9
9
  export * as level from './level/index.js'
10
10
  export * as lighten from './lighten/index.js'
@@ -3,8 +3,8 @@ export * as brighten from './brighten/index.js'
3
3
  export * as extend from './extend/index.js'
4
4
  export * as extract from './extract/index.js'
5
5
  export * as flatten from './flatten/index.js'
6
- export * as flip from './flip/index.js'
7
6
  export * as flop from './flop/index.js'
7
+ export * as flip from './flip/index.js'
8
8
  export * as hue from './hue/index.js'
9
9
  export * as level from './level/index.js'
10
10
  export * as lighten from './lighten/index.js'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@design-edito/tools",
3
- "version": "0.5.3",
3
+ "version": "0.5.5",
4
4
  "description": "",
5
5
  "author": "Maxime Fabas",
6
6
  "license": "ISC",