UNPKG

@yeger/vue-masonry-wall

Version:

Responsive masonry layout with SSR support and zero dependencies for Vue 3.

1 lines 13.5 kB
{"version":3,"file":"index.mjs","names":[],"sources":["../src/masonry-wall.vue","../src/masonry-wall.vue"],"sourcesContent":["<script setup lang=\"ts\" generic=\"T\">\nimport { debounce } from '@yeger/debounce'\nimport type { VNode } from 'vue'\nimport { nextTick, onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from 'vue'\n\nexport type NonEmptyArray<T> = [T, ...T[]]\n\nexport type Column = number[]\n\nexport type KeyMapper<T> = (\n item: T,\n column: number,\n row: number,\n index: number,\n) => string | number | symbol | undefined\n\nconst {\n columnWidth = 400,\n gap = 0,\n items,\n maxColumns,\n minColumns = 1,\n rtl = false,\n scrollContainer = null,\n ssrColumns = 0,\n} = defineProps<{\n columnWidth?: number | NonEmptyArray<number> | undefined\n items: T[]\n gap?: number | undefined\n rtl?: boolean | undefined\n ssrColumns?: number | undefined\n scrollContainer?: HTMLElement | null | undefined\n minColumns?: number | undefined\n maxColumns?: number | undefined\n keyMapper?: KeyMapper<T> | undefined\n}>()\n\nconst emit = defineEmits<{\n (event: 'redraw'): void\n (event: 'redrawSkip'): void\n}>()\n\ndefineSlots<{\n default?: (props: {\n item: T\n column: number\n columnCount: number\n row: number\n index: number\n }) => VNode | VNode[] | Element | Element[]\n}>()\n\nconst columns = ref<Column[]>([])\nconst wall = useTemplateRef<HTMLDivElement>('wall')\n\nfunction createColumns(count: number): Column[] {\n return Array.from({ length: count }).map(() => [])\n}\n\nfunction countIteratively(\n containerWidth: number,\n gap: number,\n count: number,\n consumed: number,\n): number {\n const nextWidth = getColumnWidthTarget(count)\n if (consumed + gap + nextWidth <= containerWidth) {\n return countIteratively(containerWidth, gap, count + 1, consumed + gap + nextWidth)\n }\n return count\n}\n\nfunction getColumnWidthTarget(columnIndex: number): number {\n const widths = Array.isArray(columnWidth) ? columnWidth : [columnWidth]\n return widths[columnIndex % widths.length]!\n}\n\nfunction columnCount(): number {\n const count = countIteratively(\n wall.value!.getBoundingClientRect().width,\n gap,\n 0,\n // Needs to be offset my negative gap to prevent gap counts being off by one\n -gap,\n )\n const boundedCount = aboveMin(belowMax(count))\n return boundedCount > 0 ? boundedCount : 1\n}\n\nfunction belowMax(count: number): number {\n if (!maxColumns) {\n return count\n }\n return Math.min(count, maxColumns)\n}\n\nfunction aboveMin(count: number): number {\n return Math.max(count, minColumns)\n}\n\nif (ssrColumns > 0) {\n const newColumns = createColumns(ssrColumns)\n for (let i = 0; i < items.length; i++) {\n newColumns[i % ssrColumns]!.push(i)\n }\n columns.value = newColumns\n}\n\nlet currentRedrawId = 0\n\nasync function fillColumns(itemIndex: number, assignedRedrawId: number) {\n if (itemIndex >= items.length) {\n return\n }\n await nextTick()\n if (currentRedrawId !== assignedRedrawId) {\n // Skip if a new redraw has been requested in parallel,\n // e.g., in an onMounted hook during initial render\n return\n }\n const columnDivs = [...wall.value!.children] as HTMLDivElement[]\n const target = columnDivs.reduce((prev, curr) =>\n curr.getBoundingClientRect().height < prev.getBoundingClientRect().height ? curr : prev,\n )\n columns.value[+target.dataset.index!]!.push(itemIndex)\n await fillColumns(itemIndex + 1, assignedRedrawId)\n}\n\nasync function redraw(force = false) {\n const newColumnCount = columnCount()\n if (columns.value.length === newColumnCount && !force) {\n emit('redrawSkip')\n return\n }\n columns.value = createColumns(newColumnCount)\n const scrollY = scrollContainer ? scrollContainer.scrollTop : window.scrollY\n await fillColumns(0, ++currentRedrawId)\n if (scrollContainer) {\n scrollContainer.scrollBy({ top: scrollY - scrollContainer.scrollTop })\n } else {\n window.scrollTo({ top: scrollY })\n }\n emit('redraw')\n}\n\nconst resizeObserver =\n typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(debounce(() => redraw()))\n\nonMounted(async () => {\n await redraw()\n resizeObserver?.observe(wall.value!)\n})\n\nonBeforeUnmount(() => resizeObserver?.unobserve(wall.value!))\n\nwatch(\n () => items,\n () => redraw(true),\n)\nwatch([() => columnWidth, () => gap, () => minColumns, () => maxColumns], () => redraw())\n</script>\n\n<template>\n <div\n ref=\"wall\"\n class=\"masonry-wall\"\n :style=\"{ display: 'flex', gap: `${gap}px`, flexDirection: rtl ? 'row-reverse' : undefined }\"\n >\n <div\n v-for=\"(column, columnIndex) in columns\"\n :key=\"columnIndex\"\n class=\"masonry-column\"\n :data-index=\"columnIndex\"\n :style=\"{\n display: 'flex',\n 'flex-basis': `${getColumnWidthTarget(columnIndex)}px`,\n 'flex-direction': 'column',\n 'flex-grow': 1,\n gap: `${gap}px`,\n height: 'max-content',\n 'min-width': 0,\n }\"\n >\n <div\n v-for=\"(itemIndex, row) in column\"\n :key=\"keyMapper?.(items[itemIndex]!, columnIndex, row, itemIndex) ?? itemIndex\"\n class=\"masonry-item\"\n >\n <slot\n :item=\"items[itemIndex]!\"\n :column=\"columnIndex\"\n :column-count=\"columns.length\"\n :row=\"row\"\n :index=\"itemIndex\"\n >\n {{ items[itemIndex] }}\n </slot>\n </div>\n </div>\n </div>\n</template>\n","<script setup lang=\"ts\" generic=\"T\">\nimport { debounce } from '@yeger/debounce'\nimport type { VNode } from 'vue'\nimport { nextTick, onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from 'vue'\n\nexport type NonEmptyArray<T> = [T, ...T[]]\n\nexport type Column = number[]\n\nexport type KeyMapper<T> = (\n item: T,\n column: number,\n row: number,\n index: number,\n) => string | number | symbol | undefined\n\nconst {\n columnWidth = 400,\n gap = 0,\n items,\n maxColumns,\n minColumns = 1,\n rtl = false,\n scrollContainer = null,\n ssrColumns = 0,\n} = defineProps<{\n columnWidth?: number | NonEmptyArray<number> | undefined\n items: T[]\n gap?: number | undefined\n rtl?: boolean | undefined\n ssrColumns?: number | undefined\n scrollContainer?: HTMLElement | null | undefined\n minColumns?: number | undefined\n maxColumns?: number | undefined\n keyMapper?: KeyMapper<T> | undefined\n}>()\n\nconst emit = defineEmits<{\n (event: 'redraw'): void\n (event: 'redrawSkip'): void\n}>()\n\ndefineSlots<{\n default?: (props: {\n item: T\n column: number\n columnCount: number\n row: number\n index: number\n }) => VNode | VNode[] | Element | Element[]\n}>()\n\nconst columns = ref<Column[]>([])\nconst wall = useTemplateRef<HTMLDivElement>('wall')\n\nfunction createColumns(count: number): Column[] {\n return Array.from({ length: count }).map(() => [])\n}\n\nfunction countIteratively(\n containerWidth: number,\n gap: number,\n count: number,\n consumed: number,\n): number {\n const nextWidth = getColumnWidthTarget(count)\n if (consumed + gap + nextWidth <= containerWidth) {\n return countIteratively(containerWidth, gap, count + 1, consumed + gap + nextWidth)\n }\n return count\n}\n\nfunction getColumnWidthTarget(columnIndex: number): number {\n const widths = Array.isArray(columnWidth) ? columnWidth : [columnWidth]\n return widths[columnIndex % widths.length]!\n}\n\nfunction columnCount(): number {\n const count = countIteratively(\n wall.value!.getBoundingClientRect().width,\n gap,\n 0,\n // Needs to be offset my negative gap to prevent gap counts being off by one\n -gap,\n )\n const boundedCount = aboveMin(belowMax(count))\n return boundedCount > 0 ? boundedCount : 1\n}\n\nfunction belowMax(count: number): number {\n if (!maxColumns) {\n return count\n }\n return Math.min(count, maxColumns)\n}\n\nfunction aboveMin(count: number): number {\n return Math.max(count, minColumns)\n}\n\nif (ssrColumns > 0) {\n const newColumns = createColumns(ssrColumns)\n for (let i = 0; i < items.length; i++) {\n newColumns[i % ssrColumns]!.push(i)\n }\n columns.value = newColumns\n}\n\nlet currentRedrawId = 0\n\nasync function fillColumns(itemIndex: number, assignedRedrawId: number) {\n if (itemIndex >= items.length) {\n return\n }\n await nextTick()\n if (currentRedrawId !== assignedRedrawId) {\n // Skip if a new redraw has been requested in parallel,\n // e.g., in an onMounted hook during initial render\n return\n }\n const columnDivs = [...wall.value!.children] as HTMLDivElement[]\n const target = columnDivs.reduce((prev, curr) =>\n curr.getBoundingClientRect().height < prev.getBoundingClientRect().height ? curr : prev,\n )\n columns.value[+target.dataset.index!]!.push(itemIndex)\n await fillColumns(itemIndex + 1, assignedRedrawId)\n}\n\nasync function redraw(force = false) {\n const newColumnCount = columnCount()\n if (columns.value.length === newColumnCount && !force) {\n emit('redrawSkip')\n return\n }\n columns.value = createColumns(newColumnCount)\n const scrollY = scrollContainer ? scrollContainer.scrollTop : window.scrollY\n await fillColumns(0, ++currentRedrawId)\n if (scrollContainer) {\n scrollContainer.scrollBy({ top: scrollY - scrollContainer.scrollTop })\n } else {\n window.scrollTo({ top: scrollY })\n }\n emit('redraw')\n}\n\nconst resizeObserver =\n typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(debounce(() => redraw()))\n\nonMounted(async () => {\n await redraw()\n resizeObserver?.observe(wall.value!)\n})\n\nonBeforeUnmount(() => resizeObserver?.unobserve(wall.value!))\n\nwatch(\n () => items,\n () => redraw(true),\n)\nwatch([() => columnWidth, () => gap, () => minColumns, () => maxColumns], () => redraw())\n</script>\n\n<template>\n <div\n ref=\"wall\"\n class=\"masonry-wall\"\n :style=\"{ display: 'flex', gap: `${gap}px`, flexDirection: rtl ? 'row-reverse' : undefined }\"\n >\n <div\n v-for=\"(column, columnIndex) in columns\"\n :key=\"columnIndex\"\n class=\"masonry-column\"\n :data-index=\"columnIndex\"\n :style=\"{\n display: 'flex',\n 'flex-basis': `${getColumnWidthTarget(columnIndex)}px`,\n 'flex-direction': 'column',\n 'flex-grow': 1,\n gap: `${gap}px`,\n height: 'max-content',\n 'min-width': 0,\n }\"\n >\n <div\n v-for=\"(itemIndex, row) in column\"\n :key=\"keyMapper?.(items[itemIndex]!, columnIndex, row, itemIndex) ?? itemIndex\"\n class=\"masonry-item\"\n >\n <slot\n :item=\"items[itemIndex]!\"\n :column=\"columnIndex\"\n :column-count=\"columns.length\"\n :row=\"row\"\n :index=\"itemIndex\"\n >\n {{ items[itemIndex] }}\n </slot>\n </div>\n </div>\n </div>\n</template>\n"],"mappings":"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"}