Skip to content

Commit

Permalink
pull-to-refresh(WIP)
Browse files Browse the repository at this point in the history
  • Loading branch information
malangcat committed Jan 20, 2025
1 parent f7aba7f commit 865b119
Show file tree
Hide file tree
Showing 11 changed files with 325 additions and 1 deletion.
16 changes: 15 additions & 1 deletion examples/stackflow-spa/src/design-system/stackflow/AppScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { AppScreen as SeedAppScreen, type AppScreenProps } from "@seed-design/stackflow";
import { PullToRefresh, usePullToRefreshContext } from "@seed-design/react/primitive";
import { useActions } from "@stackflow/react";
import { forwardRef } from "react";
import { theme } from "../../stackflow/theme";
import { ProgressCircle } from "../ui/progress-circle";

export const AppScreen = forwardRef<HTMLDivElement, AppScreenProps>(
({ children, onSwipeEnd, ...otherProps }, ref) => {
Expand All @@ -20,10 +22,22 @@ export const AppScreen = forwardRef<HTMLDivElement, AppScreenProps>(
{...otherProps}
>
<SeedAppScreen.Dim />
<SeedAppScreen.Layer>{children}</SeedAppScreen.Layer>
<PullToRefresh.Root>
<PullToRefresh.Indicator render={(props) => <ProgressCircle {...props} />} />
<SeedAppScreen.Layer>
<PullToRefresh.Container>{children}</PullToRefresh.Container>
</SeedAppScreen.Layer>
<Debug />
</PullToRefresh.Root>
<SeedAppScreen.Edge />
</SeedAppScreen.Root>
);
},
);
AppScreen.displayName = "AppScreen";

function Debug() {
const { state } = usePullToRefreshContext();
console.log(state);
return null;
}
49 changes: 49 additions & 0 deletions packages/react-headless/pull-to-refresh/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
{
"name": "@seed-design/react-pull-to-refresh",
"version": "0.0.0",
"repository": {
"type": "git",
"url": "git+https://github.com/daangn/seed-design.git",
"directory": "packages/react-headless/pull-to-refresh"
},
"sideEffects": false,
"exports": {
".": {
"types": "./lib/index.d.ts",
"require": "./lib/index.js",
"import": "./lib/index.mjs"
},
"./package.json": "./package.json"
},
"main": "./lib/index.js",
"files": [
"lib",
"src"
],
"scripts": {
"prepack": "yarn build",
"clean": "rm -rf lib",
"build": "nanobundle build"
},
"dependencies": {
"@radix-ui/react-compose-refs": "^1.1.1",
"@seed-design/dom-utils": "0.0.0-alpha-20241030023710",
"@seed-design/react-primitive": "0.0.0"
},
"devDependencies": {
"nanobundle": "^1.6.0"
},
"peerDependencies": {
"react": ">=18.0.0",
"react-dom": ">=18.0.0"
},
"publishConfig": {
"access": "public"
},
"ultra": {
"concurrent": [
"dev",
"build"
]
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export {
PullToRefreshRoot as Root,
PullToRefreshIndicator as Indicator,
PullToRefreshContainer as Container,
type PullToRefreshRootProps as RootProps,
type PullToRefreshIndicatorProps as IndicatorProps,
type PullToRefreshContainerProps as ContainerProps,
} from "./PullToRefresh";
42 changes: 42 additions & 0 deletions packages/react-headless/pull-to-refresh/src/PullToRefresh.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { mergeProps } from "@seed-design/dom-utils";
import { Primitive, type PrimitiveProps } from "@seed-design/react-primitive";
import { forwardRef } from "react";
import {
usePullToRefresh,
type PullToRefreshIndicatorRenderProps,
type UsePullToRefreshProps,
} from "./usePullToRefresh";
import { PullToRefreshProvider, usePullToRefreshContext } from "./usePullToRefreshContext";
import { composeRefs } from "@radix-ui/react-compose-refs";

export interface PullToRefreshRootProps extends UsePullToRefreshProps {
children?: React.ReactNode;
}

export const PullToRefreshRoot = ({ children, ...otherProps }: PullToRefreshRootProps) => {
const api = usePullToRefresh(otherProps);

return <PullToRefreshProvider value={api}>{children}</PullToRefreshProvider>;
};

export interface PullToRefreshIndicatorProps {
render: (props: PullToRefreshIndicatorRenderProps) => React.ReactNode;
}

export const PullToRefreshIndicator = ({ render }: PullToRefreshIndicatorProps) => {
const { getIndicatorRenderProps } = usePullToRefreshContext();
return render(getIndicatorRenderProps());
};

export interface PullToRefreshContainerProps
extends PrimitiveProps,
React.HTMLAttributes<HTMLDivElement> {}

export const PullToRefreshContainer = forwardRef<HTMLDivElement, PullToRefreshContainerProps>(
(props, ref) => {
const { refs, containerProps } = usePullToRefreshContext();
const mergedProps = mergeProps(containerProps, props);
return <Primitive.div ref={composeRefs(refs.containerRef, ref)} {...mergedProps} />;
},
);
PullToRefreshContainer.displayName = "PullToRefreshContainer";
15 changes: 15 additions & 0 deletions packages/react-headless/pull-to-refresh/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export {
PullToRefreshRoot,
PullToRefreshIndicator,
PullToRefreshContainer,
type PullToRefreshRootProps,
type PullToRefreshIndicatorProps,
type PullToRefreshContainerProps,
} from "./PullToRefresh";

export {
usePullToRefreshContext,
type UsePullToRefreshContext,
} from "./usePullToRefreshContext";

export * as PullToRefresh from "./PullToRefresh.namespace";
137 changes: 137 additions & 0 deletions packages/react-headless/pull-to-refresh/src/usePullToRefresh.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { elementProps } from "@seed-design/dom-utils";
import { useRef, useState } from "react";

interface UsePullToRefreshStateProps {
/**
* The threshold value to trigger the refresh.
* @default 80
*/
threshold?: number;

onReady?: () => void;

onRefresh?: () => Promise<void>;
}

interface PullToRefreshContext {
y0: number;

displacement: number;

displacementRatio: number;
}

export type PullToRefreshState = "idle" | "pulling" | "ready" | "loading";

function usePullToRefreshState(props: UsePullToRefreshStateProps) {
const threshold = props.threshold ?? 80;

const [state, setState] = useState("idle");
const containerRef = useRef<HTMLDivElement | null>(null);
const contextRef = useRef<PullToRefreshContext>({
y0: 0,
displacement: 0,
displacementRatio: 0,
});

function setContext({ y0, displacement }: Omit<PullToRefreshContext, "displacementRatio">) {
contextRef.current = {
y0,
displacement,
displacementRatio: displacement / threshold,
};
containerRef.current?.style.setProperty("--ptr-displacement", `${displacement}px`);
}

const events = {
start: ({ y0 }: { y0: number }) => {
if (state !== "idle") {
return;
}
setContext({ y0, displacement: 0 });
setState("pulling");
},
move: ({ y }: { y: number }) => {
if (state !== "pulling" && state !== "ready") {
return;
}

const { y0 } = contextRef.current;
const displacement = y - y0;
setContext({ y0, displacement });

if (displacement > threshold) {
setState("ready");
props.onReady?.();
} else {
setState("pulling");
}
},
end: () => {
if (state === "ready" && props.onRefresh) {
setState("loading");
props.onRefresh().then(() => {
setState("idle");
setContext({ y0: 0, displacement: 0 });
});
} else {
setState("idle");
setContext({ y0: 0, displacement: 0 });
}
},
};

return {
state,
refs: { containerRef },
contextRef,
events,
};
}

export interface UsePullToRefreshProps extends UsePullToRefreshStateProps {}

export interface PullToRefreshIndicatorRenderProps {
minValue: number;
maxValue: number;
value: number;
}

export type UsePullToRefreshReturn = ReturnType<typeof usePullToRefresh>;

export function usePullToRefresh(props: UsePullToRefreshProps) {
const { state, contextRef, refs, events } = usePullToRefreshState(props);

const stateProps = elementProps({
"data-ptr-state": state,
});

return {
state,

refs,
stateProps,
getIndicatorRenderProps: () => {
return {
minValue: 0,
maxValue: 100,
value: contextRef.current.displacementRatio * 100,
};
},
containerProps: elementProps({
...stateProps,
onTouchStart: (e: React.TouchEvent) => {
events.start({ y0: e.touches[0].clientY });
},
onTouchMove: (e: React.TouchEvent) => {
events.move({ y: e.touches[0].clientY });
},
onTouchEnd: () => {
events.end();
},
style: {
transform: "translateY(var(--ptr-displacement, 0))",
},
}),
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { createContext, useContext } from "react";
import type { UsePullToRefreshReturn } from "./usePullToRefresh";

export interface UsePullToRefreshContext extends UsePullToRefreshReturn {}

const PullToRefreshContext = createContext<UsePullToRefreshContext | null>(null);

export const PullToRefreshProvider = PullToRefreshContext.Provider;

export function usePullToRefreshContext<T extends boolean | undefined = true>({
strict = true,
}: { strict?: T } = {}): T extends false
? UsePullToRefreshContext | null
: UsePullToRefreshContext {
const context = useContext(PullToRefreshContext);
if (!context && strict) {
throw new Error("usePullToRefreshContext must be used within a PullToRefresh");
}

return context as UsePullToRefreshContext;
}
20 changes: 20 additions & 0 deletions packages/react-headless/pull-to-refresh/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleDetection": "force",
"moduleResolution": "Bundler",
"verbatimModuleSyntax": true,

"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noPropertyAccessFromIndexSignature": true,
"noUnusedLocals": true,
"noUnusedParameters": true,

"rootDir": "src",
"outDir": "lib",
"jsx": "react-jsx"
}
}
1 change: 1 addition & 0 deletions packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
"@seed-design/react-popover": "0.0.0-alpha-20241030023710",
"@seed-design/react-primitive": "0.0.0",
"@seed-design/react-progress": "0.0.0",
"@seed-design/react-pull-to-refresh": "0.0.0",
"@seed-design/react-radio-group": "0.0.0-alpha-20241030023710",
"@seed-design/react-segmented-control": "0.0.0",
"@seed-design/react-snackbar": "0.0.0",
Expand Down
1 change: 1 addition & 0 deletions packages/react/src/primitive.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from "@seed-design/react-checkbox";
export * from "@seed-design/react-radio-group";
export * from "@seed-design/react-pull-to-refresh";
16 changes: 16 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -7880,6 +7880,20 @@ __metadata:
languageName: unknown
linkType: soft

"@seed-design/react-pull-to-refresh@npm:0.0.0, @seed-design/react-pull-to-refresh@workspace:packages/react-headless/pull-to-refresh":
version: 0.0.0-use.local
resolution: "@seed-design/react-pull-to-refresh@workspace:packages/react-headless/pull-to-refresh"
dependencies:
"@radix-ui/react-compose-refs": "npm:^1.1.1"
"@seed-design/dom-utils": "npm:0.0.0-alpha-20241030023710"
"@seed-design/react-primitive": "npm:0.0.0"
nanobundle: "npm:^1.6.0"
peerDependencies:
react: ">=18.0.0"
react-dom: ">=18.0.0"
languageName: unknown
linkType: soft

"@seed-design/react-radio-group@npm:0.0.0-alpha-20241030023710, @seed-design/react-radio-group@workspace:packages/react-headless/radio-group":
version: 0.0.0-use.local
resolution: "@seed-design/react-radio-group@workspace:packages/react-headless/radio-group"
Expand Down Expand Up @@ -8012,6 +8026,7 @@ __metadata:
"@seed-design/react-popover": "npm:0.0.0-alpha-20241030023710"
"@seed-design/react-primitive": "npm:0.0.0"
"@seed-design/react-progress": "npm:0.0.0"
"@seed-design/react-pull-to-refresh": "npm:0.0.0"
"@seed-design/react-radio-group": "npm:0.0.0-alpha-20241030023710"
"@seed-design/react-segmented-control": "npm:0.0.0"
"@seed-design/react-snackbar": "npm:0.0.0"
Expand Down Expand Up @@ -8116,6 +8131,7 @@ __metadata:
dependencies:
"@radix-ui/react-compose-refs": "npm:^1.1.1"
"@radix-ui/react-slot": "npm:^1.1.1"
"@radix-ui/react-use-callback-ref": "npm:^1.1.0"
"@seed-design/dom-utils": "npm:0.0.0-alpha-20241030023710"
"@stackflow/core": "npm:^1.1.0"
clsx: "npm:^2.1.1"
Expand Down

0 comments on commit 865b119

Please sign in to comment.