etudes 6.2.0 → 6.2.2

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.
package/README.md CHANGED
@@ -1,3 +1,3 @@
1
- # Études [![npm](https://img.shields.io/npm/v/etudes.svg)](https://www.npmjs.com/package/etudes) [![CI](https://github.com/andrewscwei/etudes/workflows/CI/badge.svg)](https://github.com/andrewscwei/etudes/actions?query=workflow%3ACI) [![CD](https://github.com/andrewscwei/etudes/workflows/CD/badge.svg)](https://github.com/andrewscwei/etudes/actions?query=workflow%3ACD)
1
+ # Études [![npm](https://img.shields.io/npm/v/etudes.svg)](https://www.npmjs.com/package/etudes) [![CD](https://github.com/andrewscwei/etudes/workflows/CD/badge.svg)](https://github.com/andrewscwei/etudes/actions?query=workflow%3ACD)
2
2
 
3
3
  A study of headless React components.
@@ -6,42 +6,15 @@ import { useTimeout } from '../hooks/useTimeout.js';
6
6
  import { Each } from '../operators/Each.js';
7
7
  import { asStyleDict, styles } from '../utils/index.js';
8
8
  export const Carousel = forwardRef(({ autoAdvanceInterval = 0, index = 0, isDragEnabled = true, items = [], orientation = 'horizontal', tracksItemExposure = false, onAutoAdvancePause, onAutoAdvanceResume, onIndexChange, ItemComponent, ...props }, ref) => {
9
- const getItemExposures = () => {
10
- const viewportElement = viewportRef.current;
11
- if (!viewportElement)
12
- return undefined;
13
- const exposures = [];
14
- for (let i = 0; i < viewportElement.children.length; i++) {
15
- exposures.push(getItemExposureAt(i));
16
- }
17
- return exposures;
18
- };
19
- const getItemExposureAt = (idx) => {
20
- const viewportElement = viewportRef.current;
21
- const child = viewportElement?.children[idx];
22
- if (!child)
23
- return 0;
24
- const intersection = Rect.intersecting(child, viewportElement);
25
- if (!intersection)
26
- return 0;
27
- switch (orientation) {
28
- case 'horizontal':
29
- return Math.max(0, Math.min(1, Math.round((intersection.width / viewportElement.clientWidth + Number.EPSILON) * 1000) / 1000));
30
- case 'vertical':
31
- return Math.max(0, Math.min(1, Math.round((intersection.height / viewportElement.clientHeight + Number.EPSILON) * 1000) / 1000));
32
- default:
33
- throw new Error(`Unsupported orientation '${orientation}'`);
34
- }
35
- };
36
- const handleIndexChange = (newValue) => {
37
- onIndexChange?.(newValue);
38
- };
9
+ const handleIndexChange = (newValue) => onIndexChange?.(newValue);
39
10
  const handlePointerDown = (event) => {
40
11
  pointerDownPositionRef.current = Point.make(event.clientX, event.clientY);
41
12
  setIsPointerDown(true);
42
13
  };
43
14
  const handlePointerUp = (event) => {
44
15
  pointerUpPositionRef.current = Point.make(event.clientX, event.clientY);
16
+ if (!isPointerDown)
17
+ return;
45
18
  setIsPointerDown(false);
46
19
  };
47
20
  const handleClick = (event) => {
@@ -57,66 +30,50 @@ export const Carousel = forwardRef(({ autoAdvanceInterval = 0, index = 0, isDrag
57
30
  pointerDownPositionRef.current = undefined;
58
31
  pointerUpPositionRef.current = undefined;
59
32
  };
60
- const autoScrollToCurrentIndex = () => {
61
- const viewportElement = viewportRef.current;
62
- if (!viewportElement)
63
- return;
64
- const top = orientation === 'horizontal' ? 0 : viewportElement.clientHeight * index;
65
- const left = orientation === 'horizontal' ? viewportElement.clientWidth * index : 0;
66
- viewportElement.scrollTo({ top, left, behavior: 'smooth' });
67
- clearTimeout(autoScrollTimeoutRef.current);
68
- autoScrollTimeoutRef.current = setTimeout(() => {
69
- clearTimeout(autoScrollTimeoutRef.current);
70
- autoScrollTimeoutRef.current = undefined;
71
- }, autoScrollTimeoutMs);
72
- };
33
+ const normalizeScrollPosition = () => scrollToIndex(viewportRef, index, orientation);
73
34
  const prevIndexRef = useRef();
74
35
  const viewportRef = useRef(null);
75
36
  const pointerDownPositionRef = useRef();
76
37
  const pointerUpPositionRef = useRef();
77
- const [exposures, setExposures] = useState(getItemExposures());
78
- const autoScrollTimeoutRef = useRef();
79
- const autoScrollTimeoutMs = 1000;
38
+ const [exposures, setExposures] = useState(getItemExposures(viewportRef, orientation));
80
39
  const [isPointerDown, setIsPointerDown] = useState(false);
40
+ const fixedStyles = getFixedStyles({ scrollSnapEnabled: !isPointerDown, orientation });
41
+ const shouldAutoAdvance = autoAdvanceInterval > 0;
81
42
  useEffect(() => {
82
- const viewportElement = viewportRef.current;
83
- if (!viewportElement)
43
+ const viewport = viewportRef.current;
44
+ if (!viewport)
84
45
  return;
85
- const scrollHandler = () => {
46
+ const isInitialRender = prevIndexRef.current === undefined;
47
+ const isIndexModifiedFromManualScrolling = prevIndexRef.current === index;
48
+ const scrollHandler = (e) => {
86
49
  if (tracksItemExposure) {
87
- setExposures(getItemExposures());
50
+ setExposures(getItemExposures(viewportRef, orientation));
88
51
  }
89
- if (autoScrollTimeoutRef.current !== undefined)
90
- return;
91
52
  const newIndex = orientation === 'horizontal'
92
- ? Math.round(viewportElement.scrollLeft / viewportElement.clientWidth)
93
- : Math.round(viewportElement.scrollTop / viewportElement.clientHeight);
53
+ ? Math.round(viewport.scrollLeft / viewport.clientWidth)
54
+ : Math.round(viewport.scrollTop / viewport.clientHeight);
94
55
  const clampedIndex = Math.max(0, Math.min(items.length - 1, newIndex));
95
56
  if (clampedIndex === index)
96
57
  return;
97
- // Set previous index ref here to avoid the side-effect of handling index
98
- // changes from prop/state.
58
+ // Set previous index before emitting index change event to differentiate
59
+ // between index change from scroll vs from prop.
99
60
  prevIndexRef.current = clampedIndex;
100
61
  handleIndexChange(clampedIndex);
101
62
  };
102
- viewportElement.addEventListener('scroll', scrollHandler);
63
+ viewport.addEventListener('scroll', scrollHandler);
64
+ if (!isIndexModifiedFromManualScrolling) {
65
+ prevIndexRef.current = index;
66
+ if (!isInitialRender) {
67
+ handleIndexChange(index);
68
+ normalizeScrollPosition();
69
+ }
70
+ }
103
71
  return () => {
104
- viewportElement.removeEventListener('scroll', scrollHandler);
72
+ viewport.removeEventListener('scroll', scrollHandler);
105
73
  };
106
- }, [index, orientation]);
74
+ }, [index, orientation, tracksItemExposure]);
107
75
  useEffect(() => {
108
- const isInitialRender = prevIndexRef.current === undefined;
109
- const isIndexModifiedFromManualScrolling = prevIndexRef.current === index;
110
- if (isIndexModifiedFromManualScrolling)
111
- return;
112
- prevIndexRef.current = index;
113
- if (isInitialRender)
114
- return;
115
- handleIndexChange(index);
116
- autoScrollToCurrentIndex();
117
- }, [index, orientation]);
118
- useEffect(() => {
119
- if (autoAdvanceInterval <= 0)
76
+ if (!shouldAutoAdvance)
120
77
  return;
121
78
  if (isPointerDown) {
122
79
  onAutoAdvancePause?.();
@@ -124,64 +81,104 @@ export const Carousel = forwardRef(({ autoAdvanceInterval = 0, index = 0, isDrag
124
81
  else {
125
82
  onAutoAdvanceResume?.();
126
83
  }
127
- }, [isPointerDown]);
84
+ }, [isPointerDown, shouldAutoAdvance]);
128
85
  useDragEffect(viewportRef, {
129
86
  isEnabled: isDragEnabled && items.length > 1,
130
- onDragMove: (displacement) => {
87
+ onDragMove: ({ x, y }) => {
131
88
  switch (orientation) {
132
89
  case 'horizontal':
133
90
  requestAnimationFrame(() => {
134
91
  if (!viewportRef.current)
135
92
  return;
136
- viewportRef.current.scrollLeft += displacement.x * 1.5;
93
+ viewportRef.current.scrollLeft += x * 1.5;
137
94
  });
138
95
  break;
139
96
  case 'vertical':
140
97
  requestAnimationFrame(() => {
141
98
  if (!viewportRef.current)
142
99
  return;
143
- viewportRef.current.scrollTop += displacement.y * 1.5;
100
+ viewportRef.current.scrollTop += y * 1.5;
144
101
  });
145
102
  break;
146
103
  default:
147
104
  throw Error(`Unsupported orientation '${orientation}'`);
148
105
  }
149
106
  },
150
- }, [orientation, isDragEnabled]);
151
- useTimeout(() => handleIndexChange((index + items.length + 1) % items.length), (isPointerDown || autoAdvanceInterval <= 0) ? -1 : autoAdvanceInterval, [isPointerDown, index, items.length]);
152
- const fixedStyles = getFixedStyles({ isPointerDown, orientation });
153
- return (_jsx("div", { ...props, ref: ref, role: 'region', onClick: event => handleClick(event), onPointerDown: event => handlePointerDown(event), onPointerLeave: event => handlePointerUp(event), onPointerUp: event => handlePointerUp(event), children: _jsx("div", { ref: viewportRef, style: styles(fixedStyles.viewport), children: _jsx(Each, { in: items, children: ({ style: itemStyle, ...itemProps }, idx) => (_jsx("div", { style: styles(fixedStyles.itemContainer), children: _jsx(ItemComponent, { "aria-hidden": idx !== index, exposure: tracksItemExposure ? exposures?.[idx] : undefined, style: styles(itemStyle, fixedStyles.item), ...itemProps }) })) }) }) }));
107
+ }, [isDragEnabled, items.length, orientation]);
108
+ useTimeout((isPointerDown || !shouldAutoAdvance) ? -1 : autoAdvanceInterval, {
109
+ onTimeout: () => {
110
+ const nextIndex = (index + items.length + 1) % items.length;
111
+ handleIndexChange(nextIndex);
112
+ },
113
+ }, [autoAdvanceInterval, isPointerDown, index, items.length, shouldAutoAdvance, handleIndexChange]);
114
+ return (_jsx("div", { ...props, ref: ref, role: 'region', onClick: event => handleClick(event), onPointerCancel: event => handlePointerUp(event), onPointerDown: event => handlePointerDown(event), onPointerLeave: event => handlePointerUp(event), onPointerUp: event => handlePointerUp(event), children: _jsx("div", { ref: viewportRef, style: styles(fixedStyles.viewport), children: _jsx(Each, { in: items, children: ({ style: itemStyle, ...itemProps }, idx) => (_jsx("div", { style: styles(fixedStyles.itemContainer), children: _jsx(ItemComponent, { "aria-hidden": idx !== index, exposure: tracksItemExposure ? exposures?.[idx] : undefined, style: styles(itemStyle, fixedStyles.item), ...itemProps }) })) }) }) }));
154
115
  });
155
- function getFixedStyles({ isPointerDown = false, orientation = 'horizontal' }) {
116
+ function scrollToIndex(ref, index, orientation) {
117
+ const viewport = ref.current;
118
+ if (!viewport)
119
+ return;
120
+ const top = orientation === 'horizontal' ? 0 : viewport.clientHeight * index;
121
+ const left = orientation === 'horizontal' ? viewport.clientWidth * index : 0;
122
+ if (viewport.scrollTop === top && viewport.scrollLeft === left)
123
+ return;
124
+ viewport.scrollTo({ top, left, behavior: 'smooth' });
125
+ }
126
+ function getItemExposures(ref, orientation) {
127
+ const viewport = ref.current;
128
+ if (!viewport)
129
+ return undefined;
130
+ const exposures = [];
131
+ for (let i = 0; i < viewport.children.length; i++) {
132
+ exposures.push(getItemExposureAt(i, ref, orientation));
133
+ }
134
+ return exposures;
135
+ }
136
+ function getItemExposureAt(idx, ref, orientation) {
137
+ const viewport = ref.current;
138
+ const child = viewport?.children[idx];
139
+ if (!child)
140
+ return 0;
141
+ const intersection = Rect.intersecting(child, viewport);
142
+ if (!intersection)
143
+ return 0;
144
+ switch (orientation) {
145
+ case 'horizontal':
146
+ return Math.max(0, Math.min(1, Math.round((intersection.width / viewport.clientWidth + Number.EPSILON) * 1000) / 1000));
147
+ case 'vertical':
148
+ return Math.max(0, Math.min(1, Math.round((intersection.height / viewport.clientHeight + Number.EPSILON) * 1000) / 1000));
149
+ default:
150
+ throw new Error(`Unsupported orientation '${orientation}'`);
151
+ }
152
+ }
153
+ function getFixedStyles({ scrollSnapEnabled = false, orientation = 'horizontal' }) {
156
154
  return asStyleDict({
157
155
  viewport: {
158
156
  alignItems: 'center',
159
157
  display: 'flex',
160
158
  height: '100%',
161
- userSelect: isPointerDown ? 'none' : 'auto',
159
+ userSelect: scrollSnapEnabled ? 'auto' : 'none',
162
160
  justifyContent: 'flex-start',
163
- scrollBehavior: isPointerDown ? 'auto' : 'smooth',
164
- scrollSnapStop: isPointerDown ? 'unset' : 'always',
161
+ scrollBehavior: scrollSnapEnabled ? 'smooth' : 'auto',
162
+ scrollSnapStop: scrollSnapEnabled ? 'always' : 'unset',
165
163
  WebkitOverflowScrolling: 'touch',
166
164
  width: '100%',
167
165
  ...orientation === 'horizontal' ? {
168
166
  flexDirection: 'row',
169
167
  overflowX: 'scroll',
170
168
  overflowY: 'hidden',
171
- scrollSnapType: isPointerDown ? 'none' : 'x mandatory',
169
+ scrollSnapType: scrollSnapEnabled ? 'x mandatory' : 'none',
172
170
  } : {
173
171
  flexDirection: 'column',
174
172
  overflowX: 'hidden',
175
173
  overflowY: 'scroll',
176
- scrollSnapType: isPointerDown ? 'none' : 'y mandatory',
174
+ scrollSnapType: scrollSnapEnabled ? 'y mandatory' : 'none',
177
175
  },
178
176
  },
179
177
  itemContainer: {
180
178
  height: '100%',
181
179
  overflow: 'hidden',
182
- scrollSnapAlign: 'start',
180
+ scrollSnapAlign: 'center',
183
181
  width: '100%',
184
- scrollBehavior: 'smooth',
185
182
  flex: '0 0 auto',
186
183
  },
187
184
  item: {
@@ -53,7 +53,7 @@ export const Dropdown = forwardRef(({ children, className, style, collapsesOnSel
53
53
  const numItems = items.length;
54
54
  const numVisibleItems = maxVisibleItems < 0 ? numItems : Math.min(numItems, maxVisibleItems);
55
55
  const menuLength = itemLength * numVisibleItems + itemPadding * (numVisibleItems - 1);
56
- const fixedStyles = getFixedStyles({ isCollapsed, isInverted, maxVisibleItems, menuLength, numItems, orientation });
56
+ const fixedStyles = getFixedStyles({ isCollapsed, collectionPadding, isInverted, maxVisibleItems, menuLength, numItems, orientation });
57
57
  const components = asComponentDict(children, {
58
58
  collapseIcon: DropdownCollapseIcon,
59
59
  expandIcon: DropdownExpandIcon,
@@ -1,10 +1,12 @@
1
- import { type DependencyList } from 'react';
2
- /**
3
- * Hoook for invoking a method after a set timeout.
4
- *
5
- * @param handler The method to invoke.
6
- * @param timeout Time (in milliseconds) for the timeout. If the value is
7
- * `undefined` or less than 0, the timeout is disabled.
8
- * @param deps Dependencies that trigger this effect.
9
- */
10
- export declare function useTimeout(handler: () => void, timeout?: number, deps?: DependencyList): void;
1
+ import { type DependencyList, type RefObject } from 'react';
2
+ type Options = {
3
+ autoStart?: boolean;
4
+ onTimeout?: () => void;
5
+ };
6
+ type ReturnValue = {
7
+ start: () => void;
8
+ stop: () => void;
9
+ ref: RefObject<NodeJS.Timeout | undefined>;
10
+ };
11
+ export declare function useTimeout(timeout?: number, { autoStart, onTimeout }?: Options, deps?: DependencyList): ReturnValue;
12
+ export {};
@@ -1,21 +1,29 @@
1
- import { useEffect, useRef } from 'react';
2
- /**
3
- * Hoook for invoking a method after a set timeout.
4
- *
5
- * @param handler The method to invoke.
6
- * @param timeout Time (in milliseconds) for the timeout. If the value is
7
- * `undefined` or less than 0, the timeout is disabled.
8
- * @param deps Dependencies that trigger this effect.
9
- */
10
- export function useTimeout(handler, timeout, deps = []) {
1
+ import { useCallback, useEffect, useRef } from 'react';
2
+ export function useTimeout(timeout = 0, { autoStart = true, onTimeout } = {}, deps = []) {
3
+ const timeoutRef = useRef();
11
4
  const handlerRef = useRef();
5
+ const start = useCallback(() => {
6
+ stop();
7
+ if (timeout < 0)
8
+ return;
9
+ timeoutRef.current = setTimeout(() => {
10
+ stop();
11
+ handlerRef.current?.();
12
+ }, timeout);
13
+ }, [timeout]);
14
+ const stop = useCallback(() => {
15
+ clearTimeout(timeoutRef.current);
16
+ timeoutRef.current = undefined;
17
+ }, []);
12
18
  useEffect(() => {
13
- handlerRef.current = handler;
14
- }, [handler]);
19
+ handlerRef.current = onTimeout;
20
+ }, [onTimeout]);
15
21
  useEffect(() => {
16
- if (timeout === undefined || timeout < 0)
22
+ if (timeout < 0)
17
23
  return;
18
- const timer = window.setTimeout(() => handlerRef.current?.(), timeout);
19
- return () => clearTimeout(timer);
24
+ if (autoStart)
25
+ start();
26
+ return () => stop();
20
27
  }, [timeout, ...deps]);
28
+ return { start, stop, ref: timeoutRef };
21
29
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "etudes",
3
- "version": "6.2.0",
3
+ "version": "6.2.2",
4
4
  "description": "A study of headless React components",
5
5
  "type": "module",
6
6
  "scripts": {