@wwog/react 1.3.0 → 1.3.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 +67 -0
- package/dist/index.d.mts +97 -2
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/src/components/Sundry/Observer.tsx +168 -0
- package/src/components/Sundry/index.ts +1 -0
- package/src/utils/createExternalState.test.tsx +171 -2
- package/src/utils/createExternalState.ts +35 -0
package/README.md
CHANGED
|
@@ -313,6 +313,69 @@ function Example() {
|
|
|
313
313
|
- `format`: Optional function to format the date, defaults to `toLocaleString()`.
|
|
314
314
|
- `children`: Function to render the formatted date, receives the formatted date as an argument.
|
|
315
315
|
|
|
316
|
+
#### `<Observer>` (v1.3.1+)
|
|
317
|
+
|
|
318
|
+
A declarative Intersection Observer component for lazy loading, infinite scrolling, and viewport-based interactions.
|
|
319
|
+
|
|
320
|
+
```tsx
|
|
321
|
+
import { Observer } from "@wwog/react";
|
|
322
|
+
|
|
323
|
+
function LazyImage({ src, alt }) {
|
|
324
|
+
const [isVisible, setIsVisible] = useState(false);
|
|
325
|
+
|
|
326
|
+
return (
|
|
327
|
+
<Observer
|
|
328
|
+
onIntersect={(entry) => {
|
|
329
|
+
if (entry.isIntersecting) {
|
|
330
|
+
setIsVisible(true);
|
|
331
|
+
}
|
|
332
|
+
}}
|
|
333
|
+
threshold={0.1}
|
|
334
|
+
triggerOnce
|
|
335
|
+
>
|
|
336
|
+
<div className="image-container">
|
|
337
|
+
{isVisible ? (
|
|
338
|
+
<img src={src} alt={alt} />
|
|
339
|
+
) : (
|
|
340
|
+
<div className="placeholder">Loading...</div>
|
|
341
|
+
)}
|
|
342
|
+
</div>
|
|
343
|
+
</Observer>
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Infinite scrolling example
|
|
348
|
+
function InfiniteList({ items, onLoadMore }) {
|
|
349
|
+
return (
|
|
350
|
+
<div>
|
|
351
|
+
{items.map((item) => (
|
|
352
|
+
<div key={item.id}>{item.content}</div>
|
|
353
|
+
))}
|
|
354
|
+
<Observer
|
|
355
|
+
onIntersect={(entry) => {
|
|
356
|
+
if (entry.isIntersecting) {
|
|
357
|
+
onLoadMore();
|
|
358
|
+
}
|
|
359
|
+
}}
|
|
360
|
+
rootMargin="100px"
|
|
361
|
+
>
|
|
362
|
+
<div>Loading more...</div>
|
|
363
|
+
</Observer>
|
|
364
|
+
</div>
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
- `onIntersect`: Callback function triggered when intersection changes, receives IntersectionObserverEntry as parameter.
|
|
370
|
+
- `threshold`: Intersection threshold, can be a number (0-1) or array of numbers, defaults to 0.
|
|
371
|
+
- `root`: Root element for intersection observation, defaults to viewport.
|
|
372
|
+
- `rootMargin`: Root margin for expanding/shrinking the root's bounding box, defaults to "0px".
|
|
373
|
+
- `triggerOnce`: Whether to trigger only once, defaults to false.
|
|
374
|
+
- `disabled`: Whether to disable observation, defaults to false.
|
|
375
|
+
- `children`: Child elements to observe.
|
|
376
|
+
- `className`: CSS class name for the wrapper element.
|
|
377
|
+
- `style`: Inline styles for the wrapper element.
|
|
378
|
+
|
|
316
379
|
#### `<SizeBox>`
|
|
317
380
|
|
|
318
381
|
Create a fixed-size container for layout adjustment and spacing control.
|
|
@@ -400,6 +463,10 @@ You can also use a container wrapper element:
|
|
|
400
463
|
|
|
401
464
|
> A lightweight external state management utility that allows you to create and manage state outside the React component tree while maintaining perfect integration with components.
|
|
402
465
|
|
|
466
|
+
### `createStorageState` (v1.3.2+)
|
|
467
|
+
|
|
468
|
+
> Extends from createExternalState and uses storage to persist state, supports `localStorage` and `sessionStorage`
|
|
469
|
+
|
|
403
470
|
```tsx
|
|
404
471
|
import { createExternalState } from "@wwog/react";
|
|
405
472
|
|
package/dist/index.d.mts
CHANGED
|
@@ -372,6 +372,95 @@ interface ToggleProps<T = boolean> {
|
|
|
372
372
|
*/
|
|
373
373
|
declare const Toggle: <T>(props: ToggleProps<T>) => React$1.ReactNode;
|
|
374
374
|
|
|
375
|
+
interface ObserverProps {
|
|
376
|
+
/**
|
|
377
|
+
* @description_en Callback function when intersection occurs.
|
|
378
|
+
* @description_zh 交叉时触发的回调函数。
|
|
379
|
+
*/
|
|
380
|
+
onIntersect: (entry: IntersectionObserverEntry, observer: IntersectionObserver) => void;
|
|
381
|
+
/**
|
|
382
|
+
* @description_en Threshold(s) at which to trigger the callback. Default is 0.1.
|
|
383
|
+
* @description_zh 触发回调的阈值,默认为 0.1。
|
|
384
|
+
* @optional
|
|
385
|
+
* @default 0.1
|
|
386
|
+
*/
|
|
387
|
+
threshold?: number | number[];
|
|
388
|
+
/**
|
|
389
|
+
* @description_en The root element for intersection. Default is viewport.
|
|
390
|
+
* @description_zh 交叉的根元素,默认为视口。
|
|
391
|
+
* @optional
|
|
392
|
+
* @default null
|
|
393
|
+
*/
|
|
394
|
+
root?: Element | Document | null;
|
|
395
|
+
/**
|
|
396
|
+
* @description_en Margin around the root. Default is "0px".
|
|
397
|
+
* @description_zh 根元素周围的边距,默认为 "0px"。
|
|
398
|
+
* @optional
|
|
399
|
+
* @default "0px"
|
|
400
|
+
*/
|
|
401
|
+
rootMargin?: string;
|
|
402
|
+
/**
|
|
403
|
+
* @description_en Whether to trigger only once. Default is false.
|
|
404
|
+
* @description_zh 是否只触发一次,默认为 false。
|
|
405
|
+
* @optional
|
|
406
|
+
* @default false
|
|
407
|
+
*/
|
|
408
|
+
triggerOnce?: boolean;
|
|
409
|
+
/**
|
|
410
|
+
* @description_en Whether to disable the observer. Default is false.
|
|
411
|
+
* @description_zh 是否禁用观察者,默认为 false。
|
|
412
|
+
* @optional
|
|
413
|
+
* @default false
|
|
414
|
+
*/
|
|
415
|
+
disabled?: boolean;
|
|
416
|
+
/**
|
|
417
|
+
* @description_en Child elements to observe.
|
|
418
|
+
* @description_zh 要观察的子元素。
|
|
419
|
+
* @optional
|
|
420
|
+
*/
|
|
421
|
+
children?: ReactNode;
|
|
422
|
+
/**
|
|
423
|
+
* @description_en CSS class name.
|
|
424
|
+
* @description_zh CSS 类名。
|
|
425
|
+
* @optional
|
|
426
|
+
*/
|
|
427
|
+
className?: string;
|
|
428
|
+
/**
|
|
429
|
+
* @description_en Inline styles.
|
|
430
|
+
* @description_zh 内联样式。
|
|
431
|
+
* @optional
|
|
432
|
+
*/
|
|
433
|
+
style?: React$1.CSSProperties;
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* @description_zh 交叉观察者组件,用于监听元素与视口的交叉状态,常用于懒加载和无限滚动场景。
|
|
437
|
+
* @description_en Intersection Observer component for monitoring element-viewport intersection, commonly used for lazy loading and infinite scrolling.
|
|
438
|
+
* @component
|
|
439
|
+
* @example
|
|
440
|
+
* ```tsx
|
|
441
|
+
* // 懒加载示例
|
|
442
|
+
* <Observer onIntersect={loadImage} triggerOnce>
|
|
443
|
+
* <img data-src="image.jpg" alt="Lazy loaded" />
|
|
444
|
+
* </Observer>
|
|
445
|
+
*
|
|
446
|
+
* // 无限滚动示例
|
|
447
|
+
* <Observer onIntersect={loadMore} threshold={0.1}>
|
|
448
|
+
* <div>滚动到这里加载更多</div>
|
|
449
|
+
* </Observer>
|
|
450
|
+
*
|
|
451
|
+
* // 自定义根元素和边距
|
|
452
|
+
* <Observer
|
|
453
|
+
* onIntersect={handleIntersect}
|
|
454
|
+
* root={scrollContainer}
|
|
455
|
+
* rootMargin="100px"
|
|
456
|
+
* threshold={[0, 0.5, 1]}
|
|
457
|
+
* >
|
|
458
|
+
* <div>观察目标</div>
|
|
459
|
+
* </Observer>
|
|
460
|
+
* ```
|
|
461
|
+
*/
|
|
462
|
+
declare const Observer: React$1.FC<ObserverProps>;
|
|
463
|
+
|
|
375
464
|
interface ArrayRenderProps<T> {
|
|
376
465
|
items: T[];
|
|
377
466
|
renderItem: (item: T, index: number) => React$1.ReactNode;
|
|
@@ -565,6 +654,12 @@ interface ExternalWithKernel<T, U = T> extends ExternalState<T, U> {
|
|
|
565
654
|
* ```
|
|
566
655
|
*/
|
|
567
656
|
declare function createExternalState<T, U = T>(initialState: T | (() => T), options?: ExternalStateOptions<T, U>): ExternalState<T, U>;
|
|
657
|
+
interface StorageStateOptions<T, U> {
|
|
658
|
+
sideEffect?: (newState: T) => void;
|
|
659
|
+
transform?: Transform<T, U>;
|
|
660
|
+
storageType: "local" | "session";
|
|
661
|
+
}
|
|
662
|
+
declare function createStorageState<T, U = T>(key: string, initialState: T, options?: StorageStateOptions<T, U>): ExternalState<T, U>;
|
|
568
663
|
|
|
569
664
|
/**
|
|
570
665
|
* @description 性能优化,替代 React.Children.forEach, 回调可以返回 false 来中断循环
|
|
@@ -613,5 +708,5 @@ declare class Counter {
|
|
|
613
708
|
|
|
614
709
|
declare const safePromiseTry: <T, U extends unknown[]>(callbackFn: (...args: U) => T | PromiseLike<T>, ...args: U) => Promise<Awaited<T>>;
|
|
615
710
|
|
|
616
|
-
export { ArrayRender, Counter, DateRender, False, If, Pipe, Scope, SizeBox, Styles, Switch, Toggle, True, When, childrenLoop, createExternalState, cx, formatDate, safePromiseTry, useControlled };
|
|
617
|
-
export type { ArrayRenderProps, CreateStateListener, CxInput, DateRenderProps, ElseIfProps, ElseProps, ExternalSideEffect, ExternalState, ExternalStateOptions, ExternalWithKernel, FalseProps, IfProps, PipeProps, ScopeProps, StylesDescriptor, StylesProps, StylesType, SwitchCaseProps, SwitchDefaultProps, SwitchProps, ThenProps, ToggleProps, Transform, TrueProps, UseControlledOptions, WhenProps };
|
|
711
|
+
export { ArrayRender, Counter, DateRender, False, If, Observer, Pipe, Scope, SizeBox, Styles, Switch, Toggle, True, When, childrenLoop, createExternalState, createStorageState, cx, formatDate, safePromiseTry, useControlled };
|
|
712
|
+
export type { ArrayRenderProps, CreateStateListener, CxInput, DateRenderProps, ElseIfProps, ElseProps, ExternalSideEffect, ExternalState, ExternalStateOptions, ExternalWithKernel, FalseProps, IfProps, ObserverProps, PipeProps, ScopeProps, StorageStateOptions, StylesDescriptor, StylesProps, StylesType, SwitchCaseProps, SwitchDefaultProps, SwitchProps, ThenProps, ToggleProps, Transform, TrueProps, UseControlledOptions, WhenProps };
|
package/dist/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import
|
|
1
|
+
import a,{useMemo as g,Children as W,Fragment as E,isValidElement as H,cloneElement as B,useEffect as D,useState as A,useRef as O,useCallback as V}from"react";function P(e,n){if(e===void 0)return;let t=0;if(Array.isArray(e)){for(const r of e)if(n(r,t++)===!1)break}else n(e,t)}const Z=(e,n)=>e===n,w=e=>a.createElement(a.Fragment,null,e.children);w.displayName="Switch_Case";const N=e=>a.createElement(a.Fragment,null,e.children);N.displayName="Switch_Default";const y=e=>{const{value:n,compare:t=Z,children:r,strict:o=!1}=e,l=new Set;let s=null,u=null,f=!1;return P(r,(c,i)=>{if(!a.isValidElement(c))throw new Error(`Switch Children only accepts valid React elements at index ${i}`);const d=c.type;if(d.displayName===w.displayName){const m=c.props;if(l.has(m.value))throw new Error(`Switch found duplicate Case value at index ${i}: ${JSON.stringify(m.value)}${o?" (detected in strict mode)":""}`);if(l.add(m.value),!s&&t(n,m.value)&&(s=m.children,o===!1))return!1}else if(d.displayName===N.displayName){if(f)throw new Error(`Switch can only have one Default child at index ${i}`);if(f=!0,u=c.props.children,!o&&s)return!1}else throw new Error(`Switch Children only accepts 'Case' or 'Default' elements, found: ${String(d.displayName||d.name||d)} at index ${i}`)}),a.createElement(a.Fragment,null,s??u)};y.displayName="Switch",y.Case=w,y.Default=N,y.createTyped=function(){return{Switch:y,Case:w,Default:N}};const v=e=>a.createElement(a.Fragment,null,e.children),b=({children:e})=>a.createElement(a.Fragment,null,e),M=e=>a.createElement(a.Fragment,null,e.children);v.displayName="If_Then",b.displayName="If_Else",M.displayName="If_ElseIf";const h=({condition:e,children:n})=>{let t=null,r=null;const o=[];if(a.Children.forEach(n,l=>{if(!a.isValidElement(l))throw new Error("If component only accepts valid React elements");const s=l.type;if(s.displayName===v.displayName){if(t)throw new Error("If component can only have one Then child");t=l}else if(s.displayName===M.displayName)o.push(l);else if(s.displayName===b.displayName){if(r)throw new Error("If component can only have one Else child");r=l}else throw new Error(`If component only accepts 'Then', 'ElseIf', or 'Else' elements as children, found: ${String(s.displayName||s.name||s)}`)}),e)return t?a.createElement(a.Fragment,null,t.props.children):null;for(const l of o)if(l.props.condition)return a.createElement(a.Fragment,null,l.props.children);return r?a.createElement(a.Fragment,null,r.props.children):null};h.displayName="If",h.Then=v,h.ElseIf=M,h.Else=b,h.createTyped=function(){return{If:h,Then:v,ElseIf:M,Else:b}};const z=({condition:e,children:n})=>e?a.createElement(a.Fragment,null,n):null,L=({condition:e,children:n})=>e===!1?a.createElement(a.Fragment,null,n):null,U=({all:e,any:n,none:t,children:r,fallback:o})=>g(()=>(e&&(n||t)&&console.warn('When: Multiple condition types (all, any, none) provided; "all" takes precedence.'),!!(e&&e.length>0&&e.every(Boolean)||n&&n.length>0&&n.some(Boolean)||t&&t.length>0&&t.every(l=>!l))),[e,n,t])?a.createElement(a.Fragment,null,r):a.createElement(a.Fragment,null,o||null),G=({data:e,transform:n,render:t,fallback:r})=>{const o=g(()=>n.reduce((l,s)=>s(l),e),[e,n]);return o==null?a.createElement(a.Fragment,null,r||null):a.createElement(a.Fragment,null,t(o))},$=e=>{const{children:n,h:t,w:r,size:o,height:l,width:s,className:u}=e;return a.createElement("div",{style:{width:o||r||s,height:o||t||l,flexShrink:0},className:u},n)},q=({let:e,props:n,children:t,fallback:r})=>{const o=g(()=>typeof e=="function"?e(n):e,[e,n]);return!t||!Object.keys(o).length?a.createElement(a.Fragment,null,r||null):a.createElement(a.Fragment,null,t(o))};function F(...e){const n=new Set;for(const t of e)if(t){if(typeof t=="string")n.add(t);else if(Array.isArray(t))t.forEach(r=>n.add(r));else if(typeof t=="object")for(const[r,o]of Object.entries(t))o&&n.add(r)}return Array.from(n).join(" ")}const K=e=>typeof e=="object"&&!!e,x=({className:e,children:n,asWrapper:t=!1})=>{if(!n)return null;if(W.count(n)>1)return console.error("<Styles>: children has more than one child. Please check your code."),a.createElement(E,null,n);if(!e)return a.createElement(E,null,n);const r=typeof e=="string"?e:F(...Object.values(e));if(t)return a.createElement(t===!0?"div":t,{className:r},n);if(H(n)){const o=n;let l=o?.props?.className;return o?.type?.displayName===x.displayName&&K(l)&&(l=F(...Object.values(l))),B(n,{className:F(r,l)})}return console.error("<Styles>: children is not a valid React element. Please check your code."),a.createElement(E,null,n)};x.displayName="W/Styles";const Q=e=>{const{index:n=0,options:t,next:r,render:o}=e;D(()=>{if(t.length<n+1)throw new Error(`Index ${n} is out of bounds for options array of length ${t.length}. Defaulting to first option.`)},[n,t]);const[l,s]=A(n),u=()=>{s(f=>t.length?r?r(f,t):(f+1)%t.length:f)};return o(t[l],u)},X=({onIntersect:e,threshold:n=.1,root:t=null,rootMargin:r="0px",triggerOnce:o=!1,disabled:l=!1,children:s,className:u,style:f})=>{const c=O(null),i=O(null),d=O(!1);return D(()=>{if(l||!c.current)return;if(!window.IntersectionObserver){console.warn("IntersectionObserver is not supported in this browser");return}const m=c.current,p=I=>{I.forEach(S=>{if(S.isIntersecting){if(o&&d.current)return;e(S,i.current),o&&(d.current=!0,i.current?.unobserve(m))}})};return i.current=new IntersectionObserver(p,{root:t,rootMargin:r,threshold:n}),i.current.observe(m),()=>{i.current&&i.current.disconnect()}},[e,n,t,r,o,l]),D(()=>{o||(d.current=!1)},[o]),a.createElement("div",{ref:c,className:u,style:f},s)};function ee(e){const{items:n,renderItem:t,filter:r}=e;return n?a.createElement(E,null,n.map((o,l)=>r&&!r(o)?null:t(o,l))):(console.error("ArrayRender: items is null"),null)}function te({source:e,format:n,children:t}){const r=g(()=>{if(e instanceof Date)return e;if(typeof e=="string"||typeof e=="number"){const l=new Date(e);return isNaN(l.getTime())?null:l}return null},[e]),o=g(()=>r?n?n(r):r.toLocaleString():null,[r,n]);return!o||!t?null:a.createElement(a.Fragment,null,t(o))}const ne="onChange",re="value";function le(e){const{defaultValue:n,onBeforeChange:t,trigger:r=ne,valuePropName:o=re,props:l}=e,s=Object.prototype.hasOwnProperty.call(l,o),[u,f]=A(n),c=s?l[o]:u,i=g(()=>l[r],[l,r]),d=V(m=>{const p=typeof m=="function"?m(c):m;t&&t(p,c)===!1||(s||f(p),i&&i(p))},[s,t,c,i]);return[c,d]}function oe(e,...n){try{const t=e(...n);return t instanceof Promise?t:Promise.resolve(t)}catch(t){return Promise.reject(t)}}const Y=typeof Promise.try=="function"?Promise.try.bind(Promise):oe;function k(e,n={}){let t=typeof e=="function"?e():e;const r=[],{sideEffect:o,transform:l}=n,s=()=>{const c=t;return l?.get?l.get(c):c},u=c=>{const i=t,d=l?.get?l.get(i):i;t=l?.set?l.set(typeof c=="function"?c(d):c):typeof c=="function"?c(d):c,r.forEach(m=>m(t)),o&&Y(o,t,i).catch(m=>{console.error("Error in external state side effect, Please do it within side effects:",m)})},f=()=>{const[c,i]=a.useState(t);return a.useEffect(()=>(r.push(i),()=>{const d=r.indexOf(i);d>-1&&r.splice(d,1)}),[]),[l?.get?l.get(c):c,u]};return{get:s,set:u,use:f,useGetter:()=>{const[c]=f();return c},__listeners:r}}function ae(e,n,t){const{storageType:r="local",sideEffect:o,transform:l}=t??{},s=r==="local"?localStorage:sessionStorage;let u=n;const f=s.getItem(e);if(f)try{u=JSON.parse(f)}catch(c){console.warn(`Failed to parse ${r}Storage value for key "${e}", using initial state:`,c),u=n}return k(u,{sideEffect:c=>{s.setItem(e,JSON.stringify(c)),o?.(c)},transform:l})}function se(e,n){const t=n||new Date,r=t.getFullYear(),o=t.getMonth()+1,l=t.getDate(),s=t.getHours(),u=t.getMinutes(),f=t.getSeconds(),c=t.getMilliseconds(),i=t.getDay(),d=["Sun","Mon","Tue","Wed","Thu","Fri","Sat"],m=["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],p=["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"],I=["January","February","March","April","May","June","July","August","September","October","November","December"],S=m[i],T=d[i],C=o-1,j=I[C],J=p[C],R={YY:r.toString().slice(2),YYYY:r.toString(),M:o.toString(),MM:o.toString().padStart(2,"0"),MMM:J,MMMM:j,D:l.toString(),DD:l.toString().padStart(2,"0"),d:i.toString(),dd:T,ddd:T,dddd:S,H:s.toString(),HH:s.toString().padStart(2,"0"),h:(s%12).toString(),hh:(s%12).toString().padStart(2,"0"),m:u.toString(),mm:u.toString().padStart(2,"0"),s:f.toString(),ss:f.toString().padStart(2,"0"),SSS:c.toString().padStart(3,"0"),Z:"+08:00",ZZ:"+0800",A:s<12?"AM":"PM",a:s<12?"am":"pm"};return e.replace(/YYYY|YY|M{1,4}|D{1,2}|d{1,4}|H{1,2}|h{1,2}|m{1,2}|s{1,2}|SSS|Z{1,2}|A|a/g,_=>R[_])}class ce{count=0;next(){return this.count++}}export{ee as ArrayRender,ce as Counter,te as DateRender,L as False,h as If,X as Observer,G as Pipe,q as Scope,$ as SizeBox,x as Styles,y as Switch,Q as Toggle,z as True,U as When,P as childrenLoop,k as createExternalState,ae as createStorageState,F as cx,se as formatDate,Y as safePromiseTry,le as useControlled};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wwog/react",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.2",
|
|
4
4
|
"description": "A practical React component library providing declarative flow control and common UI utility components to make your React code more concise and readable.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"react",
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import React, { useEffect, useRef, type ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
export interface ObserverProps {
|
|
4
|
+
/**
|
|
5
|
+
* @description_en Callback function when intersection occurs.
|
|
6
|
+
* @description_zh 交叉时触发的回调函数。
|
|
7
|
+
*/
|
|
8
|
+
onIntersect: (entry: IntersectionObserverEntry, observer: IntersectionObserver) => void;
|
|
9
|
+
/**
|
|
10
|
+
* @description_en Threshold(s) at which to trigger the callback. Default is 0.1.
|
|
11
|
+
* @description_zh 触发回调的阈值,默认为 0.1。
|
|
12
|
+
* @optional
|
|
13
|
+
* @default 0.1
|
|
14
|
+
*/
|
|
15
|
+
threshold?: number | number[];
|
|
16
|
+
/**
|
|
17
|
+
* @description_en The root element for intersection. Default is viewport.
|
|
18
|
+
* @description_zh 交叉的根元素,默认为视口。
|
|
19
|
+
* @optional
|
|
20
|
+
* @default null
|
|
21
|
+
*/
|
|
22
|
+
root?: Element | Document | null;
|
|
23
|
+
/**
|
|
24
|
+
* @description_en Margin around the root. Default is "0px".
|
|
25
|
+
* @description_zh 根元素周围的边距,默认为 "0px"。
|
|
26
|
+
* @optional
|
|
27
|
+
* @default "0px"
|
|
28
|
+
*/
|
|
29
|
+
rootMargin?: string;
|
|
30
|
+
/**
|
|
31
|
+
* @description_en Whether to trigger only once. Default is false.
|
|
32
|
+
* @description_zh 是否只触发一次,默认为 false。
|
|
33
|
+
* @optional
|
|
34
|
+
* @default false
|
|
35
|
+
*/
|
|
36
|
+
triggerOnce?: boolean;
|
|
37
|
+
/**
|
|
38
|
+
* @description_en Whether to disable the observer. Default is false.
|
|
39
|
+
* @description_zh 是否禁用观察者,默认为 false。
|
|
40
|
+
* @optional
|
|
41
|
+
* @default false
|
|
42
|
+
*/
|
|
43
|
+
disabled?: boolean;
|
|
44
|
+
/**
|
|
45
|
+
* @description_en Child elements to observe.
|
|
46
|
+
* @description_zh 要观察的子元素。
|
|
47
|
+
* @optional
|
|
48
|
+
*/
|
|
49
|
+
children?: ReactNode;
|
|
50
|
+
/**
|
|
51
|
+
* @description_en CSS class name.
|
|
52
|
+
* @description_zh CSS 类名。
|
|
53
|
+
* @optional
|
|
54
|
+
*/
|
|
55
|
+
className?: string;
|
|
56
|
+
/**
|
|
57
|
+
* @description_en Inline styles.
|
|
58
|
+
* @description_zh 内联样式。
|
|
59
|
+
* @optional
|
|
60
|
+
*/
|
|
61
|
+
style?: React.CSSProperties;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/*
|
|
65
|
+
提供声明式的观察者API封装。
|
|
66
|
+
简化懒加载、无限滚动等常见场景的实现。
|
|
67
|
+
支持灵活的配置选项和一次性触发。
|
|
68
|
+
*/
|
|
69
|
+
/**
|
|
70
|
+
* @description_zh 交叉观察者组件,用于监听元素与视口的交叉状态,常用于懒加载和无限滚动场景。
|
|
71
|
+
* @description_en Intersection Observer component for monitoring element-viewport intersection, commonly used for lazy loading and infinite scrolling.
|
|
72
|
+
* @component
|
|
73
|
+
* @example
|
|
74
|
+
* ```tsx
|
|
75
|
+
* // 懒加载示例
|
|
76
|
+
* <Observer onIntersect={loadImage} triggerOnce>
|
|
77
|
+
* <img data-src="image.jpg" alt="Lazy loaded" />
|
|
78
|
+
* </Observer>
|
|
79
|
+
*
|
|
80
|
+
* // 无限滚动示例
|
|
81
|
+
* <Observer onIntersect={loadMore} threshold={0.1}>
|
|
82
|
+
* <div>滚动到这里加载更多</div>
|
|
83
|
+
* </Observer>
|
|
84
|
+
*
|
|
85
|
+
* // 自定义根元素和边距
|
|
86
|
+
* <Observer
|
|
87
|
+
* onIntersect={handleIntersect}
|
|
88
|
+
* root={scrollContainer}
|
|
89
|
+
* rootMargin="100px"
|
|
90
|
+
* threshold={[0, 0.5, 1]}
|
|
91
|
+
* >
|
|
92
|
+
* <div>观察目标</div>
|
|
93
|
+
* </Observer>
|
|
94
|
+
* ```
|
|
95
|
+
*/
|
|
96
|
+
export const Observer: React.FC<ObserverProps> = ({
|
|
97
|
+
onIntersect,
|
|
98
|
+
threshold = 0.1,
|
|
99
|
+
root = null,
|
|
100
|
+
rootMargin = "0px",
|
|
101
|
+
triggerOnce = false,
|
|
102
|
+
disabled = false,
|
|
103
|
+
children,
|
|
104
|
+
className,
|
|
105
|
+
style,
|
|
106
|
+
}) => {
|
|
107
|
+
const elementRef = useRef<HTMLDivElement>(null);
|
|
108
|
+
const observerRef = useRef<IntersectionObserver | null>(null);
|
|
109
|
+
const hasTriggeredRef = useRef(false);
|
|
110
|
+
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
if (disabled || !elementRef.current) {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// 检查浏览器支持
|
|
117
|
+
if (!window.IntersectionObserver) {
|
|
118
|
+
console.warn('IntersectionObserver is not supported in this browser');
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const element = elementRef.current;
|
|
123
|
+
|
|
124
|
+
const handleIntersect = (entries: IntersectionObserverEntry[]) => {
|
|
125
|
+
entries.forEach((entry) => {
|
|
126
|
+
if (entry.isIntersecting) {
|
|
127
|
+
if (triggerOnce && hasTriggeredRef.current) {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
onIntersect(entry, observerRef.current!);
|
|
132
|
+
|
|
133
|
+
if (triggerOnce) {
|
|
134
|
+
hasTriggeredRef.current = true;
|
|
135
|
+
observerRef.current?.unobserve(element);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
observerRef.current = new IntersectionObserver(handleIntersect, {
|
|
142
|
+
root,
|
|
143
|
+
rootMargin,
|
|
144
|
+
threshold,
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
observerRef.current.observe(element);
|
|
148
|
+
|
|
149
|
+
return () => {
|
|
150
|
+
if (observerRef.current) {
|
|
151
|
+
observerRef.current.disconnect();
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
}, [onIntersect, threshold, root, rootMargin, triggerOnce, disabled]);
|
|
155
|
+
|
|
156
|
+
// 重置触发状态(当 triggerOnce 从 true 变为 false 时)
|
|
157
|
+
useEffect(() => {
|
|
158
|
+
if (!triggerOnce) {
|
|
159
|
+
hasTriggeredRef.current = false;
|
|
160
|
+
}
|
|
161
|
+
}, [triggerOnce]);
|
|
162
|
+
|
|
163
|
+
return (
|
|
164
|
+
<div ref={elementRef} className={className} style={style}>
|
|
165
|
+
{children}
|
|
166
|
+
</div>
|
|
167
|
+
);
|
|
168
|
+
};
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { expect, describe, it, vi } from "vitest";
|
|
1
|
+
import { expect, describe, it, vi, beforeEach } from "vitest";
|
|
2
2
|
import { render } from "vitest-browser-react";
|
|
3
3
|
import {
|
|
4
4
|
createExternalState,
|
|
5
|
+
createStorageState,
|
|
5
6
|
type ExternalWithKernel,
|
|
6
7
|
} from "./createExternalState";
|
|
7
|
-
import { safePromiseTry } from "./promise";
|
|
8
8
|
import React from "react";
|
|
9
9
|
|
|
10
10
|
describe("createExternalState", () => {
|
|
@@ -219,3 +219,172 @@ describe("createExternalState", () => {
|
|
|
219
219
|
expect(state.get()).toBe("TEST");
|
|
220
220
|
});
|
|
221
221
|
});
|
|
222
|
+
|
|
223
|
+
describe("createStorageState", () => {
|
|
224
|
+
beforeEach(() => {
|
|
225
|
+
// 清理localStorage和sessionStorage
|
|
226
|
+
localStorage.clear();
|
|
227
|
+
sessionStorage.clear();
|
|
228
|
+
vi.clearAllMocks();
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("测试localStorage初始状态", () => {
|
|
232
|
+
const state = createStorageState("test-key", "initial", {
|
|
233
|
+
storageType: "local",
|
|
234
|
+
});
|
|
235
|
+
expect(state.get()).toBe("initial");
|
|
236
|
+
expect(localStorage.getItem("test-key")).toBeNull();
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("测试localStorage状态持久化", () => {
|
|
240
|
+
const state = createStorageState("test-key", "initial", {
|
|
241
|
+
storageType: "local",
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
state.set("updated");
|
|
245
|
+
expect(state.get()).toBe("updated");
|
|
246
|
+
expect(localStorage.getItem("test-key")).toBe('"updated"');
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("测试从localStorage恢复状态", () => {
|
|
250
|
+
// 预先设置localStorage值
|
|
251
|
+
localStorage.setItem("test-key", '"stored-value"');
|
|
252
|
+
|
|
253
|
+
const state = createStorageState("test-key", "initial", {
|
|
254
|
+
storageType: "local",
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
expect(state.get()).toBe("stored-value");
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("测试sessionStorage状态持久化", () => {
|
|
261
|
+
const state = createStorageState("test-key", "initial", {
|
|
262
|
+
storageType: "session",
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
state.set("session-updated");
|
|
266
|
+
expect(state.get()).toBe("session-updated");
|
|
267
|
+
expect(sessionStorage.getItem("test-key")).toBe('"session-updated"');
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("测试从sessionStorage恢复状态", () => {
|
|
271
|
+
// 预先设置sessionStorage值
|
|
272
|
+
sessionStorage.setItem("test-key", '"session-stored"');
|
|
273
|
+
|
|
274
|
+
const state = createStorageState("test-key", "initial", {
|
|
275
|
+
storageType: "session",
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
expect(state.get()).toBe("session-stored");
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("测试复杂对象的存储和恢复", () => {
|
|
282
|
+
interface User {
|
|
283
|
+
name: string;
|
|
284
|
+
age: number;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const initialUser: User = { name: "张三", age: 25 };
|
|
288
|
+
const state = createStorageState<User>("user-key", initialUser, {
|
|
289
|
+
storageType: "local",
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
const updatedUser: User = { name: "李四", age: 30 };
|
|
293
|
+
state.set(updatedUser);
|
|
294
|
+
|
|
295
|
+
expect(state.get()).toEqual(updatedUser);
|
|
296
|
+
expect(JSON.parse(localStorage.getItem("user-key")!)).toEqual(updatedUser);
|
|
297
|
+
|
|
298
|
+
// 创建新实例验证恢复
|
|
299
|
+
const newState = createStorageState<User>("user-key", initialUser, {
|
|
300
|
+
storageType: "local",
|
|
301
|
+
});
|
|
302
|
+
expect(newState.get()).toEqual(updatedUser);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it("测试存储解析错误处理", () => {
|
|
306
|
+
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
307
|
+
|
|
308
|
+
// 设置无效的JSON数据
|
|
309
|
+
localStorage.setItem("test-key", "invalid-json");
|
|
310
|
+
|
|
311
|
+
const state = createStorageState("test-key", "fallback", {
|
|
312
|
+
storageType: "local",
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
expect(state.get()).toBe("fallback");
|
|
316
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
317
|
+
expect.stringContaining('Failed to parse localStorage value for key "test-key"'),
|
|
318
|
+
expect.any(Error)
|
|
319
|
+
);
|
|
320
|
+
|
|
321
|
+
consoleSpy.mockRestore();
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it("测试存储副作用函数", () => {
|
|
325
|
+
const mockSideEffect = vi.fn();
|
|
326
|
+
const state = createStorageState("test-key", "initial" as string, {
|
|
327
|
+
storageType: "local",
|
|
328
|
+
sideEffect: mockSideEffect,
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
state.set("updated");
|
|
332
|
+
|
|
333
|
+
expect(mockSideEffect).toHaveBeenCalledTimes(1);
|
|
334
|
+
expect(mockSideEffect).toHaveBeenCalledWith("updated");
|
|
335
|
+
expect(localStorage.getItem("test-key")).toBe('"updated"');
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it("测试存储状态的transform功能", () => {
|
|
339
|
+
const state = createStorageState("test-key", "hello", {
|
|
340
|
+
storageType: "local",
|
|
341
|
+
transform: {
|
|
342
|
+
get: (str) => str.toUpperCase(),
|
|
343
|
+
set: (str) => str.toLowerCase(),
|
|
344
|
+
},
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
expect(state.get()).toBe("HELLO");
|
|
348
|
+
|
|
349
|
+
state.set("WORLD");
|
|
350
|
+
expect(state.get()).toBe("WORLD");
|
|
351
|
+
expect(localStorage.getItem("test-key")).toBe('"world"');
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it("测试存储状态在React组件中的使用", async () => {
|
|
355
|
+
const state = createStorageState("component-key", "initial", {
|
|
356
|
+
storageType: "local",
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
function TestComponent() {
|
|
360
|
+
const [value, setValue] = state.use();
|
|
361
|
+
return (
|
|
362
|
+
<div>
|
|
363
|
+
<span data-testid="value">{value}</span>
|
|
364
|
+
<button onClick={() => setValue("component-updated")}>Update</button>
|
|
365
|
+
</div>
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const { getByTestId, getByText } = render(<TestComponent />);
|
|
370
|
+
const valueLocator = getByTestId("value");
|
|
371
|
+
const buttonLocator = getByText("Update");
|
|
372
|
+
|
|
373
|
+
expect(valueLocator.element().textContent).toBe("initial");
|
|
374
|
+
|
|
375
|
+
await buttonLocator.click();
|
|
376
|
+
|
|
377
|
+
expect(valueLocator.element().textContent).toBe("component-updated");
|
|
378
|
+
expect(state.get()).toBe("component-updated");
|
|
379
|
+
expect(localStorage.getItem("component-key")).toBe('"component-updated"');
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it("测试默认storageType为local", () => {
|
|
383
|
+
const state = createStorageState("default-key", "initial");
|
|
384
|
+
|
|
385
|
+
state.set("default-updated");
|
|
386
|
+
|
|
387
|
+
expect(localStorage.getItem("default-key")).toBe('"default-updated"');
|
|
388
|
+
expect(sessionStorage.getItem("default-key")).toBeNull();
|
|
389
|
+
});
|
|
390
|
+
});
|
|
@@ -200,3 +200,38 @@ export function createExternalState<T, U = T>(
|
|
|
200
200
|
//@ts-expect-error ignore
|
|
201
201
|
return { get, set, use, useGetter, __listeners: listeners };
|
|
202
202
|
}
|
|
203
|
+
|
|
204
|
+
export interface StorageStateOptions<T, U> {
|
|
205
|
+
sideEffect?: (newState: T) => void;
|
|
206
|
+
transform?: Transform<T, U>;
|
|
207
|
+
storageType: "local" | "session";
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function createStorageState<T, U = T>(
|
|
211
|
+
key: string,
|
|
212
|
+
initialState: T,
|
|
213
|
+
options?: StorageStateOptions<T, U>
|
|
214
|
+
) {
|
|
215
|
+
const { storageType = "local", sideEffect, transform } = options ?? {};
|
|
216
|
+
const storage = storageType === "local" ? localStorage : sessionStorage;
|
|
217
|
+
let _initState: T = initialState;
|
|
218
|
+
const storedValue = storage.getItem(key);
|
|
219
|
+
if (storedValue) {
|
|
220
|
+
try {
|
|
221
|
+
_initState = JSON.parse(storedValue);
|
|
222
|
+
} catch (error) {
|
|
223
|
+
console.warn(
|
|
224
|
+
`Failed to parse ${storageType}Storage value for key "${key}", using initial state:`,
|
|
225
|
+
error
|
|
226
|
+
);
|
|
227
|
+
_initState = initialState;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return createExternalState(_initState, {
|
|
231
|
+
sideEffect: (newState) => {
|
|
232
|
+
storage.setItem(key, JSON.stringify(newState));
|
|
233
|
+
sideEffect?.(newState);
|
|
234
|
+
},
|
|
235
|
+
transform,
|
|
236
|
+
});
|
|
237
|
+
}
|