@wwog/react 1.2.21 → 1.3.1
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 +69 -5
- package/dist/index.d.mts +81 -21
- package/dist/index.js +1 -1
- package/package.json +11 -12
- package/src/components/Sundry/Observer.tsx +168 -0
- package/src/components/Sundry/index.ts +1 -1
- /package/src/components/Sundry/{Clamp.tsx → Clamp.bak} +0 -0
package/README.md
CHANGED
|
@@ -189,7 +189,9 @@ function UserList({ users }) {
|
|
|
189
189
|
|
|
190
190
|
#### `<Clamp>` (v1.2.14+)
|
|
191
191
|
|
|
192
|
-
|
|
192
|
+
> Removed in v1.3.0. The compatibility problem is too big, the desktop web page works well, h5 has a problem.
|
|
193
|
+
|
|
194
|
+
A component for displaying text with a fixed number of lines, ellipsis, and optional extra content.
|
|
193
195
|
|
|
194
196
|
```tsx
|
|
195
197
|
import { Clamp } from "@wwog/react";
|
|
@@ -311,6 +313,69 @@ function Example() {
|
|
|
311
313
|
- `format`: Optional function to format the date, defaults to `toLocaleString()`.
|
|
312
314
|
- `children`: Function to render the formatted date, receives the formatted date as an argument.
|
|
313
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
|
+
|
|
314
379
|
#### `<SizeBox>`
|
|
315
380
|
|
|
316
381
|
Create a fixed-size container for layout adjustment and spacing control.
|
|
@@ -435,6 +500,7 @@ function ReadOnlyThemeConsumer() {
|
|
|
435
500
|
```
|
|
436
501
|
|
|
437
502
|
- `createExternalState<T>(initialState, options?)`: Creates a state accessible outside components
|
|
503
|
+
|
|
438
504
|
- `initialState`: Initial state value
|
|
439
505
|
- `options.sideEffect`: Optional side effect function, called on state updates
|
|
440
506
|
- Returns an object with methods:
|
|
@@ -442,10 +508,8 @@ function ReadOnlyThemeConsumer() {
|
|
|
442
508
|
- `set(newState)`: Update the state value
|
|
443
509
|
- `use()`: React Hook, returns `[state, setState]` for using this state in components
|
|
444
510
|
- `useGetter()`: React Hook that only returns the state value, useful when you only need to read the state
|
|
445
|
-
- `options.transform`:
|
|
446
|
-
|
|
447
|
-
- `set`
|
|
448
|
-
Use cases:
|
|
511
|
+
- `options.transform`: - `get` - `set`
|
|
512
|
+
Use cases:
|
|
449
513
|
|
|
450
514
|
- Global state management (themes, user settings, etc.)
|
|
451
515
|
- Cross-component communication
|
package/dist/index.d.mts
CHANGED
|
@@ -372,34 +372,94 @@ interface ToggleProps<T = boolean> {
|
|
|
372
372
|
*/
|
|
373
373
|
declare const Toggle: <T>(props: ToggleProps<T>) => React$1.ReactNode;
|
|
374
374
|
|
|
375
|
-
interface
|
|
375
|
+
interface ObserverProps {
|
|
376
376
|
/**
|
|
377
|
-
* @
|
|
378
|
-
* @
|
|
379
|
-
* @default 1
|
|
377
|
+
* @description_en Callback function when intersection occurs.
|
|
378
|
+
* @description_zh 交叉时触发的回调函数。
|
|
380
379
|
*/
|
|
381
|
-
|
|
382
|
-
extraContent?: React$1.ReactNode;
|
|
380
|
+
onIntersect: (entry: IntersectionObserverEntry, observer: IntersectionObserver) => void;
|
|
383
381
|
/**
|
|
384
|
-
* @
|
|
385
|
-
* @
|
|
386
|
-
* @
|
|
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
|
|
387
426
|
*/
|
|
388
|
-
|
|
427
|
+
className?: string;
|
|
389
428
|
/**
|
|
390
|
-
* @
|
|
391
|
-
* @
|
|
429
|
+
* @description_en Inline styles.
|
|
430
|
+
* @description_zh 内联样式。
|
|
431
|
+
* @optional
|
|
392
432
|
*/
|
|
393
|
-
|
|
394
|
-
wrapperStyle?: React$1.CSSProperties;
|
|
433
|
+
style?: React$1.CSSProperties;
|
|
395
434
|
}
|
|
396
435
|
/**
|
|
397
|
-
* @
|
|
398
|
-
* @description_en
|
|
399
|
-
* @
|
|
400
|
-
* @
|
|
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
|
+
* ```
|
|
401
461
|
*/
|
|
402
|
-
declare const
|
|
462
|
+
declare const Observer: React$1.FC<ObserverProps>;
|
|
403
463
|
|
|
404
464
|
interface ArrayRenderProps<T> {
|
|
405
465
|
items: T[];
|
|
@@ -642,5 +702,5 @@ declare class Counter {
|
|
|
642
702
|
|
|
643
703
|
declare const safePromiseTry: <T, U extends unknown[]>(callbackFn: (...args: U) => T | PromiseLike<T>, ...args: U) => Promise<Awaited<T>>;
|
|
644
704
|
|
|
645
|
-
export { ArrayRender,
|
|
646
|
-
export type { ArrayRenderProps,
|
|
705
|
+
export { ArrayRender, Counter, DateRender, False, If, Observer, Pipe, Scope, SizeBox, Styles, Switch, Toggle, True, When, childrenLoop, createExternalState, cx, formatDate, safePromiseTry, useControlled };
|
|
706
|
+
export type { ArrayRenderProps, CreateStateListener, CxInput, DateRenderProps, ElseIfProps, ElseProps, ExternalSideEffect, ExternalState, ExternalStateOptions, ExternalWithKernel, FalseProps, IfProps, ObserverProps, PipeProps, ScopeProps, 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 y,Children as _,Fragment as E,isValidElement as W,cloneElement as H,useEffect as I,useState as A,useRef as x,useCallback as B}from"react";function P(t,n){if(t===void 0)return;let e=0;if(Array.isArray(t)){for(const r of t)if(n(r,e++)===!1)break}else n(t,e)}const V=(t,n)=>t===n,w=t=>a.createElement(a.Fragment,null,t.children);w.displayName="Switch_Case";const N=t=>a.createElement(a.Fragment,null,t.children);N.displayName="Switch_Default";const g=t=>{const{value:n,compare:e=V,children:r,strict:o=!1}=t,l=new Set;let s=null,f=null,m=!1;return P(r,(c,i)=>{if(!a.isValidElement(c))throw new Error(`Switch Children only accepts valid React elements at index ${i}`);const u=c.type;if(u.displayName===w.displayName){const d=c.props;if(l.has(d.value))throw new Error(`Switch found duplicate Case value at index ${i}: ${JSON.stringify(d.value)}${o?" (detected in strict mode)":""}`);if(l.add(d.value),!s&&e(n,d.value)&&(s=d.children,o===!1))return!1}else if(u.displayName===N.displayName){if(m)throw new Error(`Switch can only have one Default child at index ${i}`);if(m=!0,f=c.props.children,!o&&s)return!1}else throw new Error(`Switch Children only accepts 'Case' or 'Default' elements, found: ${String(u.displayName||u.name||u)} at index ${i}`)}),a.createElement(a.Fragment,null,s??f)};g.displayName="Switch",g.Case=w,g.Default=N,g.createTyped=function(){return{Switch:g,Case:w,Default:N}};const v=t=>a.createElement(a.Fragment,null,t.children),b=({children:t})=>a.createElement(a.Fragment,null,t),M=t=>a.createElement(a.Fragment,null,t.children);v.displayName="If_Then",b.displayName="If_Else",M.displayName="If_ElseIf";const p=({condition:t,children:n})=>{let e=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(e)throw new Error("If component can only have one Then child");e=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)}`)}),t)return e?a.createElement(a.Fragment,null,e.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};p.displayName="If",p.Then=v,p.ElseIf=M,p.Else=b,p.createTyped=function(){return{If:p,Then:v,ElseIf:M,Else:b}};const Z=({condition:t,children:n})=>t?a.createElement(a.Fragment,null,n):null,z=({condition:t,children:n})=>t===!1?a.createElement(a.Fragment,null,n):null,L=({all:t,any:n,none:e,children:r,fallback:o})=>y(()=>(t&&(n||e)&&console.warn('When: Multiple condition types (all, any, none) provided; "all" takes precedence.'),!!(t&&t.length>0&&t.every(Boolean)||n&&n.length>0&&n.some(Boolean)||e&&e.length>0&&e.every(l=>!l))),[t,n,e])?a.createElement(a.Fragment,null,r):a.createElement(a.Fragment,null,o||null),G=({data:t,transform:n,render:e,fallback:r})=>{const o=y(()=>n.reduce((l,s)=>s(l),t),[t,n]);return o==null?a.createElement(a.Fragment,null,r||null):a.createElement(a.Fragment,null,e(o))},U=t=>{const{children:n,h:e,w:r,size:o,height:l,width:s,className:f}=t;return a.createElement("div",{style:{width:o||r||s,height:o||e||l,flexShrink:0},className:f},n)},q=({let:t,props:n,children:e,fallback:r})=>{const o=y(()=>typeof t=="function"?t(n):t,[t,n]);return!e||!Object.keys(o).length?a.createElement(a.Fragment,null,r||null):a.createElement(a.Fragment,null,e(o))};function F(...t){const n=new Set;for(const e of t)if(e){if(typeof e=="string")n.add(e);else if(Array.isArray(e))e.forEach(r=>n.add(r));else if(typeof e=="object")for(const[r,o]of Object.entries(e))o&&n.add(r)}return Array.from(n).join(" ")}const K=t=>typeof t=="object"&&!!t,T=({className:t,children:n,asWrapper:e=!1})=>{if(!n)return null;if(_.count(n)>1)return console.error("<Styles>: children has more than one child. Please check your code."),a.createElement(E,null,n);if(!t)return a.createElement(E,null,n);const r=typeof t=="string"?t:F(...Object.values(t));if(e)return a.createElement(e===!0?"div":e,{className:r},n);if(W(n)){const o=n;let l=o?.props?.className;return o?.type?.displayName===T.displayName&&K(l)&&(l=F(...Object.values(l))),H(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)};T.displayName="W/Styles";const Q=t=>{const{index:n=0,options:e,next:r,render:o}=t;I(()=>{if(e.length<n+1)throw new Error(`Index ${n} is out of bounds for options array of length ${e.length}. Defaulting to first option.`)},[n,e]);const[l,s]=A(n),f=()=>{s(m=>e.length?r?r(m,e):(m+1)%e.length:m)};return o(e[l],f)},X=({onIntersect:t,threshold:n=.1,root:e=null,rootMargin:r="0px",triggerOnce:o=!1,disabled:l=!1,children:s,className:f,style:m})=>{const c=x(null),i=x(null),u=x(!1);return I(()=>{if(l||!c.current)return;if(!window.IntersectionObserver){console.warn("IntersectionObserver is not supported in this browser");return}const d=c.current,h=D=>{D.forEach(S=>{if(S.isIntersecting){if(o&&u.current)return;t(S,i.current),o&&(u.current=!0,i.current?.unobserve(d))}})};return i.current=new IntersectionObserver(h,{root:e,rootMargin:r,threshold:n}),i.current.observe(d),()=>{i.current&&i.current.disconnect()}},[t,n,e,r,o,l]),I(()=>{o||(u.current=!1)},[o]),a.createElement("div",{ref:c,className:f,style:m},s)};function $(t){const{items:n,renderItem:e,filter:r}=t;return n?a.createElement(E,null,n.map((o,l)=>r&&!r(o)?null:e(o,l))):(console.error("ArrayRender: items is null"),null)}function ee({source:t,format:n,children:e}){const r=y(()=>{if(t instanceof Date)return t;if(typeof t=="string"||typeof t=="number"){const l=new Date(t);return isNaN(l.getTime())?null:l}return null},[t]),o=y(()=>r?n?n(r):r.toLocaleString():null,[r,n]);return!o||!e?null:a.createElement(a.Fragment,null,e(o))}const te="onChange",ne="value";function re(t){const{defaultValue:n,onBeforeChange:e,trigger:r=te,valuePropName:o=ne,props:l}=t,s=Object.prototype.hasOwnProperty.call(l,o),[f,m]=A(n),c=s?l[o]:f,i=y(()=>l[r],[l,r]),u=B(d=>{const h=typeof d=="function"?d(c):d;e&&e(h,c)===!1||(s||m(h),i&&i(h))},[s,e,c,i]);return[c,u]}function le(t,...n){try{const e=t(...n);return e instanceof Promise?e:Promise.resolve(e)}catch(e){return Promise.reject(e)}}const Y=typeof Promise.try=="function"?Promise.try.bind(Promise):le;function ae(t,n={}){let e=typeof t=="function"?t():t;const r=[],{sideEffect:o,transform:l}=n,s=()=>{const c=e;return l?.get?l.get(c):c},f=c=>{const i=e,u=l?.get?l.get(i):i;e=l?.set?l.set(typeof c=="function"?c(u):c):typeof c=="function"?c(u):c,r.forEach(d=>d(e)),o&&Y(o,e,i).catch(d=>{console.error("Error in external state side effect, Please do it within side effects:",d)})},m=()=>{const[c,i]=a.useState(e);return a.useEffect(()=>(r.push(i),()=>{const u=r.indexOf(i);u>-1&&r.splice(u,1)}),[]),[l?.get?l.get(c):c,f]};return{get:s,set:f,use:m,useGetter:()=>{const[c]=m();return c},__listeners:r}}function oe(t,n){const e=n||new Date,r=e.getFullYear(),o=e.getMonth()+1,l=e.getDate(),s=e.getHours(),f=e.getMinutes(),m=e.getSeconds(),c=e.getMilliseconds(),i=e.getDay(),u=["Sun","Mon","Tue","Wed","Thu","Fri","Sat"],d=["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],h=["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"],D=["January","February","March","April","May","June","July","August","September","October","November","December"],S=d[i],C=u[i],O=o-1,k=D[O],j=h[O],J={YY:r.toString().slice(2),YYYY:r.toString(),M:o.toString(),MM:o.toString().padStart(2,"0"),MMM:j,MMMM:k,D:l.toString(),DD:l.toString().padStart(2,"0"),d:i.toString(),dd:C,ddd:C,dddd:S,H:s.toString(),HH:s.toString().padStart(2,"0"),h:(s%12).toString(),hh:(s%12).toString().padStart(2,"0"),m:f.toString(),mm:f.toString().padStart(2,"0"),s:m.toString(),ss:m.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 t.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=>J[R])}class se{count=0;next(){return this.count++}}export{$ as ArrayRender,se as Counter,ee as DateRender,z as False,p as If,X as Observer,G as Pipe,q as Scope,U as SizeBox,T as Styles,g as Switch,Q as Toggle,Z as True,L as When,P as childrenLoop,ae as createExternalState,F as cx,oe as formatDate,Y as safePromiseTry,re as useControlled};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wwog/react",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.1",
|
|
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",
|
|
@@ -20,15 +20,6 @@
|
|
|
20
20
|
],
|
|
21
21
|
"homepage": "https://github.com/wwog/react",
|
|
22
22
|
"license": "MIT",
|
|
23
|
-
"scripts": {
|
|
24
|
-
"build": "unbuild",
|
|
25
|
-
"format": "biome format --write src",
|
|
26
|
-
"check": "biome check --write src",
|
|
27
|
-
"test:unit": "vitest run --browser.headless",
|
|
28
|
-
"test:types": "tsc --noEmit --skipLibCheck",
|
|
29
|
-
"all-suites": "pnpm run format && pnpm run check && pnpm run test:types && pnpm run test:unit",
|
|
30
|
-
"test:watch": "vitest"
|
|
31
|
-
},
|
|
32
23
|
"devDependencies": {
|
|
33
24
|
"@biomejs/biome": "^1.9.4",
|
|
34
25
|
"@types/react": "^19.1.2",
|
|
@@ -50,5 +41,13 @@
|
|
|
50
41
|
"node": ">= 20.0.0",
|
|
51
42
|
"pnpm": ">=8.15.0"
|
|
52
43
|
},
|
|
53
|
-
"
|
|
54
|
-
|
|
44
|
+
"scripts": {
|
|
45
|
+
"build": "unbuild",
|
|
46
|
+
"format": "biome format --write src",
|
|
47
|
+
"check": "biome check --write src",
|
|
48
|
+
"test:unit": "vitest run --browser.headless",
|
|
49
|
+
"test:types": "tsc --noEmit --skipLibCheck",
|
|
50
|
+
"all-suites": "pnpm run format && pnpm run check && pnpm run test:types && pnpm run test:unit",
|
|
51
|
+
"test:watch": "vitest"
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -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
|
+
};
|
|
File without changes
|