diff --git a/package.json b/package.json index 365d283..bec6071 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,10 @@ "types": "./dist/scroll/index.d.ts", "default": "./dist/scroll/index.js" }, + "./server": { + "types": "./dist/server/index.d.ts", + "default": "./dist/server/index.js" + }, "./rows.css": { "types": "./dist/styles/rows.css.d.ts", "default": "./dist/styles/rows.css" @@ -48,6 +52,9 @@ "scroll": [ "dist/scroll/index.d.ts" ], + "server": [ + "dist/server/index.d.ts" + ], "rows.css": [ "dist/styles/rows.css.d.ts" ], diff --git a/rollup.config.js b/rollup.config.js index 6d2d3f0..972246f 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -13,9 +13,14 @@ export default { "client/rows": "src/client/rows/index.ts", "client/columns": "src/client/columns/index.ts", "client/masonry": "src/client/masonry/index.ts", + "client/rowsProps": "src/client/rows/resolveRowsProps.ts", + "client/columnsProps": "src/client/columns/resolveColumnsProps.ts", + "client/masonryProps": "src/client/masonry/resolveMasonryProps.ts", "client/aggregate": "src/client/aggregate/index.ts", "scroll/index": "src/scroll/index.ts", + "server/index": "src/server/index.ts", "ssr/index": "src/ssr/index.ts", + "ssr/breakpoints": "src/ssr/breakpoints/index.ts", }, output: { dir: "dist" }, plugins: [dts()], diff --git a/src/client/aggregate/PhotoAlbum.tsx b/src/client/aggregate/PhotoAlbum.tsx index 8706375..dd851da 100644 --- a/src/client/aggregate/PhotoAlbum.tsx +++ b/src/client/aggregate/PhotoAlbum.tsx @@ -1,12 +1,12 @@ import RowsPhotoAlbum from "../rows"; import ColumnsPhotoAlbum from "../columns"; import MasonryPhotoAlbum from "../masonry"; -import { ColumnsPhotoAlbumProps, MasonryPhotoAlbumProps, Photo, RowsPhotoAlbumProps } from "../../types"; +import { ColumnsPhotoAlbumProps, LayoutType, MasonryPhotoAlbumProps, Photo, RowsPhotoAlbumProps } from "../../types"; type PhotoAlbumProps = - | ({ layout: "rows" } & RowsPhotoAlbumProps) - | ({ layout: "columns" } & ColumnsPhotoAlbumProps) - | ({ layout: "masonry" } & MasonryPhotoAlbumProps); + | ({ layout: Extract } & RowsPhotoAlbumProps) + | ({ layout: Extract } & ColumnsPhotoAlbumProps) + | ({ layout: Extract } & MasonryPhotoAlbumProps); export default function PhotoAlbum({ layout, ...rest }: PhotoAlbumProps) { if (layout === "rows") return ; diff --git a/src/client/columns/ColumnsPhotoAlbum.tsx b/src/client/columns/ColumnsPhotoAlbum.tsx index b3ddff3..35625ee 100644 --- a/src/client/columns/ColumnsPhotoAlbum.tsx +++ b/src/client/columns/ColumnsPhotoAlbum.tsx @@ -2,32 +2,19 @@ import { useMemo } from "react"; import { useContainerWidth } from "../hooks"; import StaticPhotoAlbum from "../../core/static"; +import resolveColumnsProps from "./resolveColumnsProps"; import computeColumnsLayout from "../../layouts/columns"; -import { resolveCommonProps, resolveResponsiveParameter } from "../../core/utils"; import { ColumnsPhotoAlbumProps, Photo } from "../../types"; -function resolveProps( - containerWidth: number | undefined, - { columns, ...rest }: Pick, "spacing" | "padding" | "componentsProps" | "columns">, -) { - return { - columns: resolveResponsiveParameter(columns, containerWidth, [5, 4, 3, 2], 1), - ...resolveCommonProps(containerWidth, rest), - }; -} - export default function ColumnsPhotoAlbum({ photos, - onClick, - sizes, breakpoints, defaultContainerWidth, - skeleton, - ...restProps + ...rest }: ColumnsPhotoAlbumProps) { const { containerRef, containerWidth } = useContainerWidth(breakpoints, defaultContainerWidth); - const { spacing, padding, componentsProps, render, columns } = resolveProps(containerWidth, restProps); + const { spacing, padding, columns, ...restProps } = resolveColumnsProps(containerWidth, { photos, ...rest }); const model = useMemo( () => @@ -37,11 +24,5 @@ export default function ColumnsPhotoAlbum({ [photos, spacing, padding, containerWidth, columns], ); - return ( - - ); + return ; } diff --git a/src/client/columns/resolveColumnsProps.ts b/src/client/columns/resolveColumnsProps.ts new file mode 100644 index 0000000..472fa80 --- /dev/null +++ b/src/client/columns/resolveColumnsProps.ts @@ -0,0 +1,13 @@ +import { resolveCommonProps, resolveResponsiveParameter } from "../../core/utils"; +import { ColumnsPhotoAlbumProps, Photo } from "../../types"; + +export default function resolveColumnsProps( + containerWidth: number | undefined, + { columns, ...rest }: ColumnsPhotoAlbumProps, +) { + return { + ...rest, + ...resolveCommonProps(containerWidth, rest), + columns: resolveResponsiveParameter(columns, containerWidth, [5, 4, 3, 2], 1), + }; +} diff --git a/src/client/masonry/MasonryPhotoAlbum.tsx b/src/client/masonry/MasonryPhotoAlbum.tsx index b30f395..8b4afd1 100644 --- a/src/client/masonry/MasonryPhotoAlbum.tsx +++ b/src/client/masonry/MasonryPhotoAlbum.tsx @@ -2,32 +2,19 @@ import { useMemo } from "react"; import { useContainerWidth } from "../hooks"; import StaticPhotoAlbum from "../../core/static"; +import resolveMasonryProps from "./resolveMasonryProps"; import computeMasonryLayout from "../../layouts/masonry"; -import { resolveCommonProps, resolveResponsiveParameter } from "../../core/utils"; import { MasonryPhotoAlbumProps, Photo } from "../../types"; -function resolveProps( - containerWidth: number | undefined, - { columns, ...rest }: Pick, "spacing" | "padding" | "componentsProps" | "columns">, -) { - return { - columns: resolveResponsiveParameter(columns, containerWidth, [5, 4, 3, 2], 1), - ...resolveCommonProps(containerWidth, rest), - }; -} - export default function MasonryPhotoAlbum({ photos, - onClick, - sizes, breakpoints, defaultContainerWidth, - skeleton, - ...restProps + ...rest }: MasonryPhotoAlbumProps) { const { containerRef, containerWidth } = useContainerWidth(breakpoints, defaultContainerWidth); - const { spacing, padding, componentsProps, render, columns } = resolveProps(containerWidth, restProps); + const { spacing, padding, columns, ...restProps } = resolveMasonryProps(containerWidth, { photos, ...rest }); const model = useMemo( () => @@ -37,11 +24,5 @@ export default function MasonryPhotoAlbum({ [photos, spacing, padding, containerWidth, columns], ); - return ( - - ); + return ; } diff --git a/src/client/masonry/resolveMasonryProps.ts b/src/client/masonry/resolveMasonryProps.ts new file mode 100644 index 0000000..dd820b2 --- /dev/null +++ b/src/client/masonry/resolveMasonryProps.ts @@ -0,0 +1,13 @@ +import { resolveCommonProps, resolveResponsiveParameter } from "../../core/utils"; +import { MasonryPhotoAlbumProps, Photo } from "../../types"; + +export default function resolveMasonryProps( + containerWidth: number | undefined, + { columns, ...rest }: MasonryPhotoAlbumProps, +) { + return { + ...rest, + ...resolveCommonProps(containerWidth, rest), + columns: resolveResponsiveParameter(columns, containerWidth, [5, 4, 3, 2], 1), + }; +} diff --git a/src/client/rows/RowsPhotoAlbum.tsx b/src/client/rows/RowsPhotoAlbum.tsx index 6ac6724..067c57a 100644 --- a/src/client/rows/RowsPhotoAlbum.tsx +++ b/src/client/rows/RowsPhotoAlbum.tsx @@ -2,46 +2,22 @@ import { useMemo } from "react"; import { useContainerWidth } from "../hooks"; import StaticPhotoAlbum from "../../core/static"; +import resolveRowsProps from "./resolveRowsProps"; import computeRowsLayout from "../../layouts/rows"; -import { resolveCommonProps, resolveResponsiveParameter, unwrapParameter } from "../../core/utils"; import { Photo, RowsPhotoAlbumProps } from "../../types"; -function resolveProps( - containerWidth: number | undefined, - { - targetRowHeight, - rowConstraints, - ...rest - }: Pick< - RowsPhotoAlbumProps, - "spacing" | "padding" | "componentsProps" | "targetRowHeight" | "rowConstraints" - >, -) { - return { - targetRowHeight: resolveResponsiveParameter(targetRowHeight, containerWidth, [ - (w) => w / 5, - (w) => w / 4, - (w) => w / 3, - (w) => w / 2, - ]), - ...unwrapParameter(rowConstraints, containerWidth), - ...resolveCommonProps(containerWidth, rest), - }; -} - export default function RowsPhotoAlbum({ photos, - onClick, - sizes, breakpoints, defaultContainerWidth, - skeleton, ...rest }: RowsPhotoAlbumProps) { const { containerRef, containerWidth } = useContainerWidth(breakpoints, defaultContainerWidth); - const { spacing, padding, componentsProps, render, targetRowHeight, minPhotos, maxPhotos, singleRowMaxHeight } = - resolveProps(containerWidth, rest); + const { spacing, padding, targetRowHeight, minPhotos, maxPhotos, ...restProps } = resolveRowsProps(containerWidth, { + photos, + ...rest, + }); const model = useMemo( () => @@ -51,25 +27,5 @@ export default function RowsPhotoAlbum({ [photos, spacing, padding, containerWidth, targetRowHeight, minPhotos, maxPhotos], ); - if (singleRowMaxHeight !== undefined && spacing !== undefined && padding !== undefined) { - const maxWidth = Math.floor( - photos.reduce( - (acc, { width, height }) => acc + (width / height) * singleRowMaxHeight - 2 * padding, - padding * photos.length * 2 + spacing * (photos.length - 1), - ), - ); - - if (maxWidth > 0) { - componentsProps.container = { ...componentsProps.container }; - componentsProps.container.style = { maxWidth, ...componentsProps.container.style }; - } - } - - return ( - - ); + return ; } diff --git a/src/client/rows/resolveRowsProps.ts b/src/client/rows/resolveRowsProps.ts new file mode 100644 index 0000000..33cbe75 --- /dev/null +++ b/src/client/rows/resolveRowsProps.ts @@ -0,0 +1,40 @@ +import { resolveCommonProps, resolveResponsiveParameter, unwrapParameter } from "../../core/utils"; +import { Photo, RowsPhotoAlbumProps } from "../../types"; + +export default function resolveRowsProps( + containerWidth: number | undefined, + { photos, targetRowHeight, rowConstraints, ...rest }: RowsPhotoAlbumProps, +) { + const { spacing, padding, componentsProps, render } = resolveCommonProps(containerWidth, rest); + const { singleRowMaxHeight, minPhotos, maxPhotos } = unwrapParameter(rowConstraints, containerWidth) || {}; + + if (singleRowMaxHeight !== undefined && spacing !== undefined && padding !== undefined) { + const maxWidth = Math.floor( + photos.reduce( + (acc, { width, height }) => acc + (width / height) * singleRowMaxHeight - 2 * padding, + padding * photos.length * 2 + spacing * (photos.length - 1), + ), + ); + + if (maxWidth > 0) { + componentsProps.container = { ...componentsProps.container }; + componentsProps.container.style = { maxWidth, ...componentsProps.container.style }; + } + } + + return { + ...rest, + targetRowHeight: resolveResponsiveParameter(targetRowHeight, containerWidth, [ + (w) => w / 5, + (w) => w / 4, + (w) => w / 3, + (w) => w / 2, + ]), + render, + spacing, + padding, + minPhotos, + maxPhotos, + componentsProps, + }; +} diff --git a/src/server/ServerPhotoAlbum.tsx b/src/server/ServerPhotoAlbum.tsx new file mode 100644 index 0000000..a005459 --- /dev/null +++ b/src/server/ServerPhotoAlbum.tsx @@ -0,0 +1,121 @@ +import { clsx } from "../core/utils"; +import StaticPhotoAlbum from "../core/static"; +import computeRowsLayout from "../layouts/rows"; +import computeColumnsLayout from "../layouts/columns"; +import computeMasonryLayout from "../layouts/masonry"; +import resolveRowsProps from "../client/rows/resolveRowsProps"; +import resolveColumnsProps from "../client/columns/resolveColumnsProps"; +import resolveMasonryProps from "../client/masonry/resolveMasonryProps"; +import { StyledBreakpoints, useBreakpoints } from "../ssr/breakpoints"; +import { + ColumnsPhotoAlbumProps, + LayoutType, + MasonryPhotoAlbumProps, + NonOptional, + Photo, + RowsPhotoAlbumProps, +} from "../types"; + +type RowsServerPhotoAlbumProps = NonOptional< + Omit, "defaultContainerWidth" | "onClick" | "skeleton">, + "breakpoints" +>; + +type ColumnsServerPhotoAlbumProps = NonOptional< + Omit, "defaultContainerWidth" | "onClick" | "skeleton">, + "breakpoints" +>; + +type MasonryServerPhotoAlbumProps = NonOptional< + Omit, "defaultContainerWidth" | "onClick" | "skeleton">, + "breakpoints" +>; + +/** ServerPhotoAlbum component props. */ +export type ServerPhotoAlbumProps = { + /** if `true`, do not include the inline stylesheet */ + unstyled?: boolean; + /** custom class names for the container and the breakpoint intervals */ + classNames?: { + /** custom container class name */ + container?: string; + /** custom class names for the breakpoint intervals */ + breakpoints?: { [key: number]: string }; + }; +} & ( + | ({ layout: Extract } & RowsServerPhotoAlbumProps) + | ({ layout: Extract } & ColumnsServerPhotoAlbumProps) + | ({ layout: Extract } & MasonryServerPhotoAlbumProps) +); + +/** Experimental ServerPhotoAlbum component. */ +export default function ServerPhotoAlbum({ + layout, + unstyled, + classNames, + breakpoints: breakpointsProp, + ...props +}: ServerPhotoAlbumProps) { + const { photos } = props; + + const { breakpoints, containerClass, breakpointClass } = useBreakpoints("server", breakpointsProp); + + if (!Array.isArray(photos) || !Array.isArray(breakpoints) || breakpoints.length === 0) return null; + + const computeModel = (breakpoint: number) => { + if (layout === "rows") { + const { spacing, padding, targetRowHeight, minPhotos, maxPhotos, ...rest } = resolveRowsProps(breakpoint, props); + + if (spacing !== undefined && padding !== undefined && targetRowHeight !== undefined) { + return { + ...rest, + model: computeRowsLayout(photos, spacing, padding, breakpoint, targetRowHeight, minPhotos, maxPhotos), + }; + } + } + + if (layout === "columns") { + const { spacing, padding, columns, ...rest } = resolveColumnsProps(breakpoint, props); + + if (spacing !== undefined && padding !== undefined && columns !== undefined) { + return { + ...rest, + model: computeColumnsLayout(photos, spacing, padding, breakpoint, columns), + }; + } + } + + if (layout === "masonry") { + const { spacing, padding, columns, ...rest } = resolveMasonryProps(breakpoint, props); + + if (spacing !== undefined && padding !== undefined && columns !== undefined) { + return { + ...rest, + model: computeMasonryLayout(photos, spacing, padding, breakpoint, columns), + }; + } + } + + return null; + }; + + return ( + <> + {!unstyled && ( + + )} + +
+ {breakpoints.map((breakpoint) => ( +
+ +
+ ))} +
+ + ); +} diff --git a/src/server/index.ts b/src/server/index.ts new file mode 100644 index 0000000..3a44b3d --- /dev/null +++ b/src/server/index.ts @@ -0,0 +1,2 @@ +export type { ServerPhotoAlbumProps as UnstableServerPhotoAlbumProps } from "./ServerPhotoAlbum"; +export { default as UnstableServerPhotoAlbum } from "./ServerPhotoAlbum"; diff --git a/src/ssr/SSR.tsx b/src/ssr/SSR.tsx index e2f0550..bf93f44 100644 --- a/src/ssr/SSR.tsx +++ b/src/ssr/SSR.tsx @@ -1,10 +1,11 @@ import type React from "react"; -import { cloneElement, isValidElement, useId, useState } from "react"; +import { cloneElement, isValidElement, useState } from "react"; import { useContainerWidth } from "../client/hooks"; -import { cssClass } from "../core/utils"; import { CommonPhotoAlbumProps } from "../types"; +import { StyledBreakpoints, useBreakpoints } from "./breakpoints/index"; +/** SSR component props. */ export type SSRProps = { /** Photo album layout breakpoints. */ breakpoints: number[]; @@ -13,8 +14,8 @@ export type SSRProps = { }; /** Experimental SSR component. */ -export default function SSR({ breakpoints, children }: SSRProps) { - const uid = `ssr-${useId().replace(/:/g, "")}`; +export default function SSR({ breakpoints: breakpointsProp, children }: SSRProps) { + const { breakpoints, containerClass, breakpointClass } = useBreakpoints("ssr", breakpointsProp); const { containerRef, containerWidth } = useContainerWidth(breakpoints); const [hydratedBreakpoint, setHydratedBreakpoint] = useState(); @@ -24,29 +25,18 @@ export default function SSR({ breakpoints, children }: SSRProps) { setHydratedBreakpoint(containerWidth); } - const containerClass = cssClass(uid); - const breakpointClass = (breakpoint: number) => cssClass(`${uid}-${breakpoint}`); - - const allBreakpoints = [Math.min(...breakpoints) / 2, ...breakpoints]; - allBreakpoints.sort((a, b) => a - b); - return ( <> {hydratedBreakpoint === undefined && ( - + )}
- {allBreakpoints.map( + {breakpoints.map( (breakpoint) => (hydratedBreakpoint === undefined || hydratedBreakpoint === breakpoint) && (
diff --git a/src/ssr/breakpoints/StyledBreakpoints.tsx b/src/ssr/breakpoints/StyledBreakpoints.tsx new file mode 100644 index 0000000..3874b41 --- /dev/null +++ b/src/ssr/breakpoints/StyledBreakpoints.tsx @@ -0,0 +1,20 @@ +type StyledBreakpointsProps = { + breakpoints: number[]; + containerClass: string; + breakpointClass: (breakpoint: number) => string; +}; + +export default function StyledBreakpoints({ breakpoints, containerClass, breakpointClass }: StyledBreakpointsProps) { + return ( + + ); +} diff --git a/src/ssr/breakpoints/index.ts b/src/ssr/breakpoints/index.ts new file mode 100644 index 0000000..b41471d --- /dev/null +++ b/src/ssr/breakpoints/index.ts @@ -0,0 +1,2 @@ +export { default as useBreakpoints } from "./useBreakpoints"; +export { default as StyledBreakpoints } from "./StyledBreakpoints"; diff --git a/src/ssr/breakpoints/useBreakpoints.ts b/src/ssr/breakpoints/useBreakpoints.ts new file mode 100644 index 0000000..72ab6de --- /dev/null +++ b/src/ssr/breakpoints/useBreakpoints.ts @@ -0,0 +1,19 @@ +import { useId } from "react"; +import { cssClass } from "../../core/utils/index"; + +function convertBreakpoints(breakpoints: number[]) { + if (!breakpoints || breakpoints.length === 0) return []; + const allBreakpoints = [Math.min(...breakpoints) / 2, ...breakpoints]; + allBreakpoints.sort((a, b) => a - b); + return allBreakpoints; +} + +export default function useBreakpoints(prefix: string, breakpoints: number[]) { + const uid = `${prefix}-${useId().replace(/:/g, "")}`; + + return { + containerClass: cssClass(uid), + breakpointClass: (breakpoint: number) => cssClass(`${uid}-${breakpoint}`), + breakpoints: convertBreakpoints(breakpoints), + }; +} diff --git a/src/types.ts b/src/types.ts index 9c94fe5..3910588 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,8 @@ import type React from "react"; +/** Layout type */ +export type LayoutType = "rows" | "columns" | "masonry"; + /** Photo object */ export interface Photo extends Image { /** React `key` attribute. */ diff --git a/test/ServerPhotoAlbum.spec.tsx b/test/ServerPhotoAlbum.spec.tsx new file mode 100644 index 0000000..d135734 --- /dev/null +++ b/test/ServerPhotoAlbum.spec.tsx @@ -0,0 +1,73 @@ +import { LayoutType } from "../src"; +import { UnstableServerPhotoAlbum as ServerPhotoAlbum } from "../src/server"; +import { render } from "./test-utils"; +import photos from "./photos"; + +describe("ServerPhotoAlbum", () => { + const breakpoints = [300, 600, 900]; + + it.each(["rows", "columns", "masonry"])(`supports %s layout`, (layout) => { + const { getPhotos } = render( + , + ); + expect(getPhotos().length).toBe(photos.length * (breakpoints.length + 1)); + }); + + it("supports custom class names", () => { + const { container } = render( + , + ); + + expect(container.querySelector(".container-class")).not.toBeNull(); + expect(container.querySelector(".breakpoint-150")).not.toBeNull(); + expect(container.querySelector(".breakpoint-300")).not.toBeNull(); + expect(container.querySelector(".breakpoint-600")).not.toBeNull(); + expect(container.querySelector(".breakpoint-900")).not.toBeNull(); + }); + + it("doesn't crash with invalid props", () => { + const { getTracks, getContainer, rerender } = render( + , + ); + expect(getTracks().length).toBe(0); + + rerender( + , + ); + expect(getContainer()).toBe(null); + + rerender( + , + ); + expect(getContainer()).toBe(null); + }); +}); diff --git a/vite.config.ts b/vite.config.ts index 575f5e8..91732ab 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -29,9 +29,14 @@ export default defineConfig({ "client/rows": "src/client/rows/index.ts", "client/columns": "src/client/columns/index.ts", "client/masonry": "src/client/masonry/index.ts", + "client/rowsProps": "src/client/rows/resolveRowsProps.ts", + "client/columnsProps": "src/client/columns/resolveColumnsProps.ts", + "client/masonryProps": "src/client/masonry/resolveMasonryProps.ts", "client/aggregate": "src/client/aggregate/index.ts", "scroll/index": "src/scroll/index.ts", + "server/index": "src/server/index.ts", "ssr/index": "src/ssr/index.ts", + "ssr/breakpoints": "src/ssr/breakpoints/index.ts", }, formats: ["es"], },