UNPKG

shufflejs

Version:

Categorize, sort, and filter a responsive grid of items

1 lines 84.5 kB
{"version":3,"file":"shuffle.mjs","names":["id","#count","#uniqueId","#transitions","#transitionManager","#queue","#getElementOption","#getItems","#validateStyles","#setColumns","#rafId","#resizeObserver","#handleResizeCallback","#getFilteredSets","#doesPassFilter","#getFilteredItems","#getGutterSize","#getColumnSize","#getContainerSize","#getNextPositions","#getStaggerAmount","#getItemPosition","#getConcealedItems","#whenTransitionDone","#cancelMovement","#startTransitions","#getTransitionFunction","#movementFinished","#filter","#shrink","#updateItemCount","#resetCols","#layout","#processQueue","#setContainerSize","#mergeNewItems"],"sources":["../src/tiny-emitter.ts","../src/parallel.ts","../src/helpers.ts","../src/point.ts","../src/rect.ts","../src/constants.ts","../src/shuffle-item.ts","../src/sorter.ts","../src/transition-manager.ts","../src/layout.ts","../src/shuffle.ts"],"sourcesContent":["// oxlint-disable typescript/no-unsafe-function-type, typescript/ban-types, typescript/no-explicit-any, typescript/no-unsafe-assignment\n/**\n * @fileoverview copy of `tiny-emitter` on npm, but converted to ESM\n */\n\nexport class TinyEmitter {\n handlers?: Record<string, { fn: Function & { onceCb?: Function }; ctx?: any }[]>;\n\n on(event: string, callback: Function, ctx?: any): this {\n const handlers = this.handlers ?? (this.handlers = {});\n\n (handlers[event] || (handlers[event] = [])).push({\n fn: callback,\n ctx,\n });\n\n return this;\n }\n\n once(event: string, callback: Function, ctx?: any): this {\n // oxlint-disable-next-line typescript/no-this-alias, unicorn/no-this-assignment\n const self = this;\n function listener() {\n self.off(event, listener);\n // oxlint-disable-next-line prefer-rest-params\n callback.apply(ctx, arguments);\n }\n\n listener.onceCb = callback;\n return this.on(event, listener, ctx);\n }\n\n emit(event: string, ...args: any[]): this {\n const data = args;\n const evtArr = [...((this.handlers ?? (this.handlers = {}))[event] || [])];\n let i = 0;\n const len = evtArr.length;\n\n for (i; i < len; i += 1) {\n evtArr[i].fn.apply(evtArr[i].ctx, data);\n }\n\n return this;\n }\n\n off(event: string, callback?: Function): this {\n const handlers = this.handlers ?? (this.handlers = {});\n const evts = handlers[event];\n const liveEvents = [];\n\n if (evts && callback) {\n for (let i = 0, len = evts.length; i < len; i += 1) {\n if (evts[i].fn !== callback && evts[i].fn.onceCb !== callback) {\n liveEvents.push(evts[i]);\n }\n }\n }\n\n // Remove event from queue to prevent memory leak\n // Suggested by https://github.com/lazd\n // Ref: https://github.com/scottcorgan/tiny-emitter/commit/c6ebfaa9bc973b33d110a84a307742b7cf94c953#commitcomment-5024910\n\n if (liveEvents.length > 0) {\n handlers[event] = liveEvents;\n } else {\n // oxlint-disable-next-line typescript/no-dynamic-delete\n delete handlers[event];\n }\n\n return this;\n }\n}\n","// oxlint-disable unicorn/explicit-length-check, no-param-reassign, no-plusplus\n// Copied and converted from https://github.com/component/array-parallel/blob/f9240097cb1edf8432111d2bf6cb6eb600da34c2/index.js\n\n/**\n * Execute an array of functions in parallel.\n * Calls the callback with an error if any function fails,\n * otherwise calls it with null and an array of results.\n */\nexport function parallel<ResultType>(\n fns: ((done: (err: Error | null, result?: ResultType) => void) => void)[],\n context?: unknown,\n callback?: (err: Error | null, results?: ResultType[]) => void,\n): void {\n if (!callback) {\n if (typeof context === 'function') {\n callback = context as (err: Error | null, results?: ResultType[]) => void;\n context = null;\n } else {\n callback = noop;\n }\n }\n\n let pending = fns && fns.length;\n\n if (!pending) {\n callback(null, []);\n return;\n }\n\n let finished = false;\n // oxlint-disable-next-line unicorn/no-new-array\n const results = new Array(pending);\n\n for (let i = 0, len = fns.length; i < len; i++) {\n const fn = fns[i];\n fn.call(context, maybeDone(i));\n }\n\n function maybeDone(i: number) {\n return function onDoneCheck(err: Error | null, result?: ResultType) {\n if (finished) {\n return;\n }\n\n if (err) {\n callback!(err, results);\n finished = true;\n return;\n }\n\n results[i] = result;\n\n if (!--pending) {\n callback!(null, results);\n }\n };\n }\n}\n\nfunction noop(): void {\n // no-op\n}\n","import type { QueueItem } from './types';\n\n/**\n * Change a property or execute a function which will not have a transition\n * @param elements DOM elements that won't be transitioned.\n * @param callback A function which will be called while transition is set to 0ms.\n */\nexport function skipTransitions(elements: HTMLElement[], callback: () => void): void {\n const zero = '0ms';\n\n // Save current duration and delay.\n const data = elements.map((element) => {\n const { style } = element;\n const duration = style.transitionDuration;\n const delay = style.transitionDelay;\n\n // Set the duration to zero so it happens immediately\n style.transitionDuration = zero;\n style.transitionDelay = zero;\n\n return {\n duration,\n delay,\n };\n });\n\n callback();\n\n // Cause forced synchronous layout.\n // oxlint-disable-next-line no-unused-expressions\n elements[0].offsetWidth;\n\n // Put the duration back\n for (let i = 0; i < elements.length; i += 1) {\n elements[i].style.transitionDuration = data[i].duration;\n elements[i].style.transitionDelay = data[i].delay;\n }\n}\n\n/**\n * Apply styles without a transition.\n * Array of transition objects.\n */\nexport function styleImmediately(objects: QueueItem[]): void {\n if (objects.length > 0) {\n const elements = objects.map((obj) => obj.item.element);\n\n skipTransitions(elements, () => {\n for (const obj of objects) {\n obj.item.applyCss(obj.styles);\n obj.callback();\n }\n });\n }\n}\n\nexport function arrayUnique<Item>(items: Item[]): Item[] {\n return [...new Set(items)];\n}\n\nconst EXPECTED_WIDTH = 10;\nlet value: boolean | null = null;\n\nexport function testComputedSize(): boolean {\n if (value !== null) {\n return value;\n }\n\n const element = document.body || document.documentElement;\n const div = document.createElement('div');\n div.style.cssText = 'width:10px;padding:2px;box-sizing:border-box;';\n element.append(div);\n\n const { width } = globalThis.getComputedStyle(div, null);\n // Fix for issue #314\n value = Math.round(getNumber(width)) === EXPECTED_WIDTH;\n\n div.remove();\n\n return value;\n}\n\nconst DEFAULT_NUMBER = 0;\n\n/**\n * Always returns a numeric value, given a value. Logic from jQuery's `isNumeric`.\n * @param value Possibly numeric value.\n * @return `value` or zero if `value` isn't numeric.\n */\n\nexport function getNumber(value: unknown): number {\n return Number.parseFloat(String(value)) || DEFAULT_NUMBER;\n}\n\n/**\n * Retrieve the computed style for an element, parsed as a float.\n * @param element Element to get style for.\n * @param style Style property.\n * @param styles Optionally include clean styles to use instead of asking for\n * them again.\n * @return The parsed computed value or zero if that fails because IE will\n * return 'auto' when the element doesn't have margins instead of the computed style.\n */\nexport function getNumberStyle(\n element: Element,\n style: keyof CSSStyleDeclaration,\n styles: CSSStyleDeclaration = globalThis.getComputedStyle(element, null),\n): number {\n let value = getNumber(styles[style]);\n\n // Support IE<=11 and W3C spec.\n if (!testComputedSize() && style === 'width') {\n value +=\n getNumber(styles.paddingLeft) +\n getNumber(styles.paddingRight) +\n getNumber(styles.borderLeftWidth) +\n getNumber(styles.borderRightWidth);\n } else if (!testComputedSize() && style === 'height') {\n value +=\n getNumber(styles.paddingTop) +\n getNumber(styles.paddingBottom) +\n getNumber(styles.borderTopWidth) +\n getNumber(styles.borderBottomWidth);\n }\n\n return value;\n}\n\n/**\n * Returns the outer width of an element, optionally including its margins.\n *\n * There are a few different methods for getting the width of an element, none of\n * which work perfectly for all Shuffle's use cases.\n *\n * 1. getBoundingClientRect() `left` and `right` properties.\n * - Accounts for transform scaled elements, making it useless for Shuffle\n * elements which have shrunk.\n * 2. The `offsetWidth` property.\n * - This value stays the same regardless of the elements transform property,\n * however, it does not return subpixel values.\n * 3. getComputedStyle()\n * - This works great Chrome, Firefox, Safari, but IE<=11 does not include\n * padding and border when box-sizing: border-box is set, requiring a feature\n * test and extra work to add the padding back for IE and other browsers which\n * follow the W3C spec here.\n *\n * @param element The element.\n * @param includeMargins Whether to include margins.\n * @return The width and height.\n */\nexport function getSize(element: HTMLElement, includeMargins = false): { width: number; height: number } {\n // Store the styles so that they can be used by others without asking for it again.\n const styles = globalThis.getComputedStyle(element, null);\n let width = getNumberStyle(element, 'width', styles);\n let height = getNumberStyle(element, 'height', styles);\n\n if (includeMargins) {\n const marginLeft = getNumberStyle(element, 'marginLeft', styles);\n const marginRight = getNumberStyle(element, 'marginRight', styles);\n const marginTop = getNumberStyle(element, 'marginTop', styles);\n const marginBottom = getNumberStyle(element, 'marginBottom', styles);\n width += marginLeft + marginRight;\n height += marginTop + marginBottom;\n }\n\n return {\n width,\n height,\n };\n}\n","import { getNumber } from './helpers';\n\nexport class Point {\n x: number;\n y: number;\n\n /**\n * Represents a coordinate pair.\n * @param x X coordinate.\n * @param y Y coordinate.\n */\n constructor(x?: number | string, y?: number | string) {\n this.x = getNumber(x);\n this.y = getNumber(y);\n }\n\n /**\n * Whether two points are equal.\n * @param pointA Point A.\n * @param pointB Point B.\n * @return Whether the points are equal.\n */\n static equals(pointA: Point, pointB: Point): boolean {\n return pointA.x === pointB.x && pointA.y === pointB.y;\n }\n}\n","export class Rect {\n id: number;\n left: number;\n top: number;\n width: number;\n height: number;\n\n /**\n * Class for representing rectangular regions.\n * https://github.com/google/closure-library/blob/master/closure/goog/math/rect.js\n * @param x Left.\n * @param y Top.\n * @param width Width.\n * @param height Height.\n * @param id Identifier.\n */\n constructor(x: number, y: number, width: number, height: number, id: number) {\n this.id = id;\n this.left = x;\n this.top = y;\n this.width = width;\n this.height = height;\n }\n\n /**\n * Returns whether two rectangles intersect.\n * @param rectA A rectangle.\n * @param rectB A rectangle.\n * @return Whether a and b intersect.\n */\n static intersects(rectA: Rect, rectB: Rect): boolean {\n return (\n rectA.left < rectB.left + rectB.width &&\n rectB.left < rectA.left + rectA.width &&\n rectA.top < rectB.top + rectB.height &&\n rectB.top < rectA.top + rectA.height\n );\n }\n}\n","import type { FilterModeOptions, ShuffleEventMap, ShuffleOptions } from './types';\n\nexport const Classes = {\n BASE: 'shuffle',\n SHUFFLE_ITEM: 'shuffle-item',\n VISIBLE: 'shuffle-item--visible',\n HIDDEN: 'shuffle-item--hidden',\n} as const;\n\nexport const ALL_ITEMS = 'all';\n\nexport const FILTER_ATTRIBUTE_KEY = 'groups';\n\nexport const FilterMode: {\n ANY: 'any';\n ALL: 'all';\n} = {\n ANY: 'any',\n ALL: 'all',\n} satisfies Record<string, FilterModeOptions>;\n\nexport const EventType: {\n LAYOUT: 'shuffle:layout';\n REMOVED: 'shuffle:removed';\n} = {\n LAYOUT: 'shuffle:layout',\n REMOVED: 'shuffle:removed',\n} satisfies Record<string, keyof ShuffleEventMap>;\n\nexport const DEFAULT_OPTIONS: ShuffleOptions = {\n // Initial filter group.\n group: ALL_ITEMS,\n\n // Transition/animation speed (milliseconds).\n speed: 250,\n\n // CSS easing function to use.\n easing: 'cubic-bezier(0.4, 0.0, 0.2, 1)',\n\n // e.g. '.picture-item'.\n itemSelector: '*',\n\n // Element or selector string. Use an element to determine the size of columns\n // and gutters.\n sizer: null,\n\n // A static number or function that tells the plugin how wide the gutters\n // between columns are (in pixels).\n gutterWidth: 0,\n\n // A static number or function that returns a number which tells the plugin\n // how wide the columns are (in pixels).\n columnWidth: 0,\n\n // If your group is not json, and is comma delimited, you could set delimiter\n // to ','.\n delimiter: null,\n\n // Useful for percentage based heights when they might not always be exactly\n // the same (in pixels).\n buffer: 0,\n\n // Reading the width of elements isn't precise enough and can cause columns to\n // jump between values.\n columnThreshold: 0.01,\n\n // Shuffle can be initialized with a sort object. It is the same object\n // given to the sort method.\n initialSort: null,\n\n // Transition delay offset for each item in milliseconds.\n staggerAmount: 15,\n\n // Maximum stagger delay in milliseconds.\n staggerAmountMax: 150,\n\n // Whether to use transforms or absolute positioning.\n useTransforms: true,\n\n // Affects using an array with filter. e.g. `filter(['one', 'two'])`. With \"any\",\n // the element passes the test if any of its groups are in the array. With \"all\",\n // the element only passes if all groups are in the array.\n // Note, this has no effect if you supply a custom filter function.\n filterMode: FilterMode.ANY,\n\n // Attempt to center grid items in each row.\n isCentered: false,\n\n // Attempt to align grid items to right.\n isRTL: false,\n\n // Whether to round pixel values used in translate(x, y). This usually avoids\n // blurriness.\n roundTransforms: true,\n} as const;\n","import { Point } from './point';\nimport { Classes } from './constants';\nimport type { FilterSet, InlineCssStyles, ShuffleItemCss } from './types';\n\nlet id = 0;\n\nexport class ShuffleItem {\n id: number;\n element: HTMLElement;\n isRTL: boolean;\n isVisible: boolean;\n isHidden: boolean;\n scale = 1;\n point: Point = new Point();\n\n constructor(element: HTMLElement, isRTL?: boolean) {\n id += 1;\n this.id = id;\n this.element = element;\n\n /**\n * Set correct direction of item\n */\n this.isRTL = Boolean(isRTL);\n\n /**\n * Used to separate items for layout and shrink.\n */\n this.isVisible = true;\n\n /**\n * Used to determine if a transition will happen. By the time the _layout\n * and _shrink methods get the ShuffleItem instances, the `isVisible` value\n * has already been changed by the separation methods, so this property is\n * needed to know if the item was visible/hidden before the shrink/layout.\n */\n this.isHidden = false;\n }\n\n show(): void {\n this.isVisible = true;\n this.element.classList.remove(Classes.HIDDEN);\n this.element.classList.add(Classes.VISIBLE);\n this.element.removeAttribute('aria-hidden');\n }\n\n hide(): void {\n this.isVisible = false;\n this.element.classList.remove(Classes.VISIBLE);\n this.element.classList.add(Classes.HIDDEN);\n this.element.setAttribute('aria-hidden', 'true');\n }\n\n init(): void {\n this.addClasses([Classes.SHUFFLE_ITEM, Classes.VISIBLE]);\n this.applyCss(ShuffleItem.Css.INITIAL);\n this.applyCss(this.isRTL ? ShuffleItem.Css.DIRECTION.rtl : ShuffleItem.Css.DIRECTION.ltr);\n this.scale = ShuffleItem.Scale.VISIBLE;\n this.point = new Point();\n }\n\n addClasses(classes: string[]): void {\n for (const className of classes) {\n this.element.classList.add(className);\n }\n }\n\n removeClasses(classes: string[]): void {\n for (const className of classes) {\n this.element.classList.remove(className);\n }\n }\n\n applyCss(obj: InlineCssStyles): void {\n for (const [key, value] of Object.entries(obj)) {\n // @ts-expect-error shrug\n this.element.style[key] = String(value);\n }\n }\n\n dispose(): void {\n this.removeClasses([Classes.HIDDEN, Classes.VISIBLE, Classes.SHUFFLE_ITEM]);\n\n this.element.removeAttribute('style');\n // @ts-expect-error nullifying for garbage collection\n this.element = null;\n }\n\n static Css: ShuffleItemCss = {\n INITIAL: {\n position: 'absolute',\n top: 0,\n visibility: 'visible',\n willChange: 'transform',\n },\n DIRECTION: {\n ltr: {\n left: 0,\n },\n rtl: {\n right: 0,\n },\n },\n VISIBLE: {\n before: {\n opacity: 1,\n visibility: 'visible',\n },\n after: {\n transitionDelay: '',\n },\n },\n HIDDEN: {\n before: {\n opacity: 0,\n },\n after: {\n visibility: 'hidden',\n transitionDelay: '',\n },\n },\n };\n\n static Scale = {\n VISIBLE: 1,\n HIDDEN: 0.001,\n };\n}\n\n/**\n * Toggles the visible and hidden class names.\n * @param Object with visible and hidden arrays.\n */\nexport function toggleFilterClasses({ visible, hidden }: FilterSet): void {\n for (const item of visible) {\n item.show();\n }\n\n for (const item of hidden) {\n item.hide();\n }\n}\n\n/**\n * Set the initial css for each item\n * @param items Set to initialize.\n */\nexport function initItems(items: ShuffleItem[]): void {\n for (const item of items) {\n item.init();\n }\n}\n\n/**\n * Remove element reference and styles.\n * @param items Set to dispose.\n */\nexport function disposeItems(items: ShuffleItem[]): void {\n for (const item of items) {\n item.dispose();\n }\n}\n\nexport function applyHiddenState(item: ShuffleItem): void {\n item.scale = ShuffleItem.Scale.HIDDEN;\n item.isHidden = true;\n item.applyCss(ShuffleItem.Css.HIDDEN.before);\n item.applyCss(ShuffleItem.Css.HIDDEN.after);\n}\n","import type { ShuffleItem } from './shuffle-item';\nimport type { SortOptions } from './types';\n\n/**\n * Fisher-Yates shuffle.\n * http://stackoverflow.com/a/962890/373422\n * https://bost.ocks.org/mike/shuffle/\n * @param array Array to shuffle.\n * @return Randomly sorted array.\n */\nfunction randomize<Item>(array: Item[]): Item[] {\n let count = array.length;\n\n while (count) {\n count -= 1;\n const index = Math.floor(Math.random() * (count + 1));\n const temp = array[index];\n array[index] = array[count];\n array[count] = temp;\n }\n\n return array;\n}\n\nconst defaults = {\n // Use array.reverse() to reverse the results\n reverse: false,\n\n // Sorting function\n by: null,\n\n // Custom sort function\n compare: null,\n\n // If true, this will skip the sorting and return a randomized order in the array\n randomize: false,\n\n // Determines which property of each item in the array is passed to the\n // sorting method.\n key: 'element',\n} as const;\n\n/**\n * You can return `undefined` from the `by` function to revert to DOM order.\n * @param arr Array to sort.\n * @param options Sorting options.\n * @return The sorted array.\n */\nexport function sorter(arr: ShuffleItem[], options?: SortOptions | null): ShuffleItem[] {\n // Handle invalid input gracefully\n if (!Array.isArray(arr)) {\n return [];\n }\n\n const opts = { ...defaults, ...options };\n const original = [...arr];\n let revert = false;\n\n if (arr.length === 0) {\n return [];\n }\n\n if (opts.randomize) {\n return randomize(arr);\n }\n\n // Sort the elements by the opts.by function.\n // If we don't have opts.by, default to DOM order\n if (typeof opts.by === 'function') {\n const sortBy = opts.by;\n arr.sort((itemA, itemB) => {\n // Exit early if we already know we want to revert\n if (revert) {\n return 0;\n }\n\n const itemAValue = itemA[opts.key];\n const itemBValue = itemB[opts.key];\n // @ts-expect-error the key is dynamic, but we know it will be a valid key of ShuffleItem\n // oxlint-disable-next-line typescript/no-unsafe-assignment\n const valA = sortBy(itemAValue);\n // @ts-expect-error dynamic key\n // oxlint-disable-next-line typescript/no-unsafe-assignment\n const valB = sortBy(itemBValue);\n\n // If both values are undefined, use the DOM order\n if (valA === undefined && valB === undefined) {\n revert = true;\n return 0;\n }\n\n if (valA < valB || valA === 'sortFirst' || valB === 'sortLast') {\n return -1;\n }\n\n if (valA > valB || valA === 'sortLast' || valB === 'sortFirst') {\n return 1;\n }\n\n return 0;\n });\n } else if (typeof opts.compare === 'function') {\n arr.sort(opts.compare);\n }\n\n // Revert to the original array if necessary\n if (revert) {\n return original;\n }\n\n if (opts.reverse) {\n arr.reverse();\n }\n\n return arr;\n}\n","type TransitionEventCallback = (event: TransitionEvent) => void;\n\ninterface Transition {\n element: HTMLElement;\n listener: TransitionEventCallback;\n}\n\nexport class TransitionManager {\n #transitions = new Map<string, Transition>();\n #count = 0;\n\n #uniqueId(): string {\n this.#count += 1;\n return `transitionend${this.#count}`;\n }\n\n waitForTransition(element: HTMLElement, callback: TransitionEventCallback): string {\n const id = this.#uniqueId();\n const listener = (event: TransitionEvent) => {\n if (event.currentTarget === event.target) {\n this.cancelTransition(id);\n callback(event);\n }\n };\n\n element.addEventListener('transitionend', listener as EventListener);\n this.#transitions.set(id, { element, listener });\n\n return id;\n }\n\n cancelTransition(id: string): boolean {\n const entry = this.#transitions.get(id);\n if (entry) {\n entry.element.removeEventListener('transitionend', entry.listener as EventListener);\n this.#transitions.delete(id);\n return true;\n }\n\n return false;\n }\n\n cancelAll(): void {\n for (const id of this.#transitions.keys()) {\n this.cancelTransition(id);\n }\n }\n}\n\nexport function createTransitionManager(): TransitionManager {\n return new TransitionManager();\n}\n","import { Point } from './point';\nimport { Rect } from './rect';\n\n/**\n * Determine the number of columns an items spans.\n * @param itemWidth Width of the item.\n * @param columnWidth Width of the column (includes gutter).\n * @param columns Total number of columns.\n * @param threshold A buffer value for the size of the column to fit.\n * @return The column span.\n */\nexport function getColumnSpan(itemWidth: number, columnWidth: number, columns: number, threshold: number): number {\n let columnSpan = itemWidth / columnWidth;\n\n // If the difference between the rounded column span number and the\n // calculated column span number is really small, round the number to\n // make it fit.\n if (Math.abs(Math.round(columnSpan) - columnSpan) < threshold) {\n // e.g. columnSpan = 4.0089945390298745\n columnSpan = Math.round(columnSpan);\n }\n\n // Ensure the column span is not more than the amount of columns in the whole layout.\n return Math.min(Math.ceil(columnSpan), columns);\n}\n\n/**\n * Retrieves the column set to use for placement.\n * @param positions The position array.\n * @param columnSpan The number of columns this current item spans.\n * @param columns The total columns in the grid.\n * @return An array of numbers representing the column set.\n */\nexport function getAvailablePositions(positions: number[], columnSpan: number, columns: number): number[] {\n // The item spans only one column.\n if (columnSpan === 1) {\n return positions;\n }\n\n // The item spans more than one column, figure out how many different\n // places it could fit horizontally.\n // The group count is the number of places within the positions this block\n // could fit, ignoring the current positions of items.\n // Imagine a 2 column brick as the second item in a 4 column grid with\n // 10px height each. Find the places it would fit:\n // [20, 10, 10, 0]\n // | | |\n // * * *\n //\n // Then take the places which fit and get the bigger of the two:\n // max([20, 10]), max([10, 10]), max([10, 0]) = [20, 10, 10]\n //\n // Next, find the first smallest number (the short column).\n // [20, 10, 10]\n // |\n // *\n //\n // And that's where it should be placed!\n //\n // Another example where the second column's item extends past the first:\n // [10, 20, 10, 0] => [20, 20, 10] => 10\n const available = [];\n\n // For how many possible positions for this item there are.\n for (let i = 0; i <= columns - columnSpan; i += 1) {\n // Find the bigger value for each place it could fit.\n available.push(Math.max(...positions.slice(i, i + columnSpan)));\n }\n\n return available;\n}\n\n/**\n * Find index of short column, the first from the left where this item will go.\n *\n * @param positions The array to search for the smallest number.\n * @param buffer Optional buffer which is very useful when the height is a percentage of the width.\n * @return Index of the short column.\n */\nexport function getShortColumn(positions: number[], buffer: number): number {\n const minPosition = Math.min(...positions);\n for (let i = 0, len = positions.length; i < len; i += 1) {\n if (positions[i] >= minPosition - buffer && positions[i] <= minPosition + buffer) {\n return i;\n }\n }\n\n return 0;\n}\n\ninterface ItemPositionParams {\n /** Object with width and height. */\n itemSize: { width: number; height: number };\n /** Positions of the other current items. */\n positions: number[];\n /** The column width or row height. */\n gridSize: number;\n /** The total number of columns or rows. */\n total: number;\n /** Buffer value for the column to fit. */\n threshold: number;\n /** Vertical buffer for the height of items. */\n buffer: number;\n}\n\n/**\n * Determine the location of the next item, based on its size.\n * @param params Object with itemSize, positions, gridSize, total, threshold, and buffer.\n * @return The point position for the item.\n */\nexport function getItemPosition({\n itemSize,\n positions,\n gridSize,\n total,\n threshold,\n buffer,\n}: ItemPositionParams): Point {\n const span = getColumnSpan(itemSize.width, gridSize, total, threshold);\n const setY = getAvailablePositions(positions, span, total);\n const shortColumnIndex = getShortColumn(setY, buffer);\n\n // Position the item\n const point = new Point(gridSize * shortColumnIndex, setY[shortColumnIndex]);\n\n // Update the columns array with the new values for each column.\n // e.g. before the update the columns could be [250, 0, 0, 0] for an item\n // which spans 2 columns. After it would be [250, itemHeight, itemHeight, 0].\n const setHeight = setY[shortColumnIndex] + itemSize.height;\n for (let i = 0; i < span; i += 1) {\n positions[shortColumnIndex + i] = setHeight;\n }\n\n return point;\n}\n\n/**\n * This method attempts to center items. This method could potentially be slow\n * with a large number of items because it must place items, then check every\n * previous item to ensure there is no overlap.\n * @param itemRects Item data objects.\n * @param containerWidth Width of the containing element.\n * @return An array of centered points.\n */\nexport function getCenteredPositions(itemRects: Rect[], containerWidth: number): Point[] {\n const rowMap: Record<number, Rect[]> = {};\n\n // Populate rows by their offset because items could jump between rows like:\n // a c\n // bbb\n for (const itemRect of itemRects) {\n if (rowMap[itemRect.top] === undefined) {\n // Start of a new row.\n rowMap[itemRect.top] = [itemRect];\n } else {\n // Push the point to the last row array.\n rowMap[itemRect.top].push(itemRect);\n }\n }\n\n // For each row, find the end of the last item, then calculate\n // the remaining space by dividing it by 2. Then add that\n // offset to the x position of each point.\n const rects: Rect[] = [];\n const rows: Rect[][] = [];\n const centeredRows: Rect[][] = [];\n for (const itemRects of Object.values(rowMap)) {\n rows.push(itemRects);\n const lastItem = itemRects.at(-1)!;\n const end = lastItem.left + lastItem.width;\n const offset = Math.round((containerWidth - end) / 2);\n\n let finalRects = itemRects;\n let canMove = false;\n if (offset > 0) {\n const newRects: Rect[] = [];\n canMove = itemRects.every((comparisonRect) => {\n const newRect = new Rect(\n comparisonRect.left + offset,\n comparisonRect.top,\n comparisonRect.width,\n comparisonRect.height,\n comparisonRect.id,\n );\n\n // Check all current rects to make sure none overlap.\n const noOverlap = !rects.some((rectangle) => Rect.intersects(newRect, rectangle));\n\n newRects.push(newRect);\n return noOverlap;\n });\n\n // If none of the rectangles overlapped, the whole group can be centered.\n if (canMove) {\n finalRects = newRects;\n }\n }\n\n // If the items are not going to be offset, ensure that the original\n // placement for this row will not overlap previous rows (row-spanning\n // elements could be in the way).\n if (!canMove) {\n let intersectingRect: Rect;\n const hasOverlap = itemRects.some((itemRect) =>\n rects.some((comparisonRect) => {\n const intersects = Rect.intersects(itemRect, comparisonRect);\n if (intersects) {\n intersectingRect = comparisonRect;\n }\n return intersects;\n }),\n );\n\n // If there is any overlap, replace the overlapping row with the original.\n if (hasOverlap) {\n const rowIndex = centeredRows.findIndex((items) => items.includes(intersectingRect));\n centeredRows.splice(rowIndex, 1, rows[rowIndex]);\n }\n }\n\n rects.push(...finalRects);\n centeredRows.push(finalRects);\n }\n\n // Reduce array of arrays to a single array of points.\n // https://stackoverflow.com/a/10865042/373422\n // Then reset sort back to how the items were passed to this method.\n // Remove the wrapper object with index, map to a Point.\n return centeredRows\n .flat()\n .toSorted((rowA, rowB) => rowA.id - rowB.id)\n .map((itemRect) => new Point(itemRect.left, itemRect.top));\n}\n","import { TinyEmitter } from './tiny-emitter';\nimport { parallel } from './parallel';\nimport { Point } from './point';\nimport { Rect } from './rect';\nimport { ShuffleItem, applyHiddenState, disposeItems, initItems, toggleFilterClasses } from './shuffle-item';\nimport type {\n ElementOption,\n FilterArg,\n FilterFunction,\n FilterSet,\n InlineCssStyles,\n QueueItem,\n ShuffleEventCallback,\n ShuffleEventData,\n ShuffleEventMap,\n ShuffleOptions,\n SortOptions,\n} from './types';\n\nimport { ALL_ITEMS, Classes, DEFAULT_OPTIONS, EventType, FILTER_ATTRIBUTE_KEY, FilterMode } from './constants';\nimport { arrayUnique, getNumberStyle, getSize, styleImmediately } from './helpers';\nimport { sorter } from './sorter';\nimport { createTransitionManager, type TransitionManager } from './transition-manager';\nimport { getItemPosition, getCenteredPositions } from './layout';\n\n// Re-export types for backward compatibility\nexport type {\n SortOptions,\n InlineCssStyles,\n ShuffleOptions,\n FilterFunction,\n FilterArg,\n ShuffleEventData,\n ShuffleEventCallback,\n};\n\n// Used for unique instance variables\nlet id = 0;\n\nclass Shuffle extends TinyEmitter {\n element: HTMLElement;\n sizer: HTMLElement | null;\n options: ShuffleOptions;\n lastSort: SortOptions | null;\n group: FilterArg;\n lastFilter: FilterArg;\n isEnabled: boolean;\n isDestroyed: boolean;\n isInitialized: boolean;\n isTransitioning: boolean;\n id: string;\n items: ShuffleItem[];\n sortedItems: ShuffleItem[];\n visibleItems!: number;\n cols!: number;\n colWidth!: number;\n containerWidth!: number;\n positions!: number[];\n #transitionManager: TransitionManager;\n #queue: QueueItem[];\n #rafId: number | null;\n #resizeObserver: ResizeObserver | null = null;\n\n /**\n * Categorize, sort, and filter a responsive grid of items.\n *\n * @param element An element which is the parent container for the grid items.\n * @param options Options object.\n * @constructor\n */\n constructor(element: ElementOption, options: Partial<ShuffleOptions> = {}) {\n super();\n this.options = { ...Shuffle.options, ...options };\n\n this.lastSort = {};\n this.group = Shuffle.ALL_ITEMS;\n this.lastFilter = Shuffle.ALL_ITEMS;\n this.isEnabled = true;\n this.isDestroyed = false;\n this.isInitialized = false;\n this.#transitionManager = createTransitionManager();\n this.isTransitioning = false;\n this.#queue = [];\n\n const el = this.#getElementOption(element);\n\n if (!el) {\n throw new TypeError('Shuffle needs to be initialized with an element.');\n }\n\n this.element = el;\n this.id = `shuffle_${id}`;\n id += 1;\n\n this.items = this.#getItems();\n this.sortedItems = this.items;\n\n this.sizer = this.#getElementOption(this.options.sizer);\n\n // Add class and invalidate styles\n this.element.classList.add(Shuffle.Classes.BASE);\n\n // Set initial css for each item\n initItems(this.items);\n\n // If the page has not already emitted the `load` event, call layout on load.\n // This avoids layout issues caused by images and fonts loading after the\n // instance has been initialized.\n if (document.readyState !== 'complete') {\n const layout = this.layout.bind(this);\n window.addEventListener('load', function onLoad() {\n window.removeEventListener('load', onLoad);\n layout();\n });\n }\n\n // Get container css all in one request. Causes reflow\n const containerCss = globalThis.getComputedStyle(this.element, null);\n const containerWidth = Shuffle.getSize(this.element).width;\n\n // Add styles to the container if it doesn't have them.\n this.#validateStyles(containerCss);\n\n // We already got the container's width above, no need to cause another\n // reflow getting it again... Calculate the number of columns there will be\n this.#setColumns(containerWidth);\n\n // Kick off!\n this.filter(this.options.group, this.options.initialSort);\n\n // Bind resize events\n this.#rafId = null;\n this.#resizeObserver = new ResizeObserver(this.#handleResizeCallback.bind(this));\n this.#resizeObserver.observe(this.element);\n\n // The shuffle items haven't had transitions set on them yet so the user\n // doesn't see the first layout. Set them now that the first layout is done.\n // First, however, a synchronous layout must be caused for the previous\n // styles to be applied without transitions.\n // oxlint-disable-next-line no-unused-expressions\n this.element.offsetWidth;\n this.setItemTransitions(this.items);\n this.element.style.transition = `height ${this.options.speed}ms ${this.options.easing}`;\n this.isInitialized = true;\n }\n\n // Override TinyEmitter methods with proper types using conditional types\n on<EventName extends keyof ShuffleEventMap>(\n event: EventName,\n callback: (data: ShuffleEventMap[EventName]) => void,\n // oxlint-disable-next-line typescript/no-explicit-any\n context?: any,\n ): this;\n // oxlint-disable-next-line typescript/no-explicit-any\n on(event: string, callback: ShuffleEventCallback, context?: any): this;\n // oxlint-disable-next-line typescript/no-explicit-any\n on(event: string, callback: ShuffleEventCallback, context?: any): this {\n return super.on(event, callback, context);\n }\n\n once<EventName extends keyof ShuffleEventMap>(\n event: EventName,\n callback: (data: ShuffleEventMap[EventName]) => void,\n // oxlint-disable-next-line typescript/no-explicit-any\n context?: any,\n ): this;\n // oxlint-disable-next-line typescript/no-explicit-any\n once(event: string, callback: ShuffleEventCallback, context?: any): this;\n // oxlint-disable-next-line typescript/no-explicit-any\n once(event: string, callback: ShuffleEventCallback, context?: any): this {\n return super.once(event, callback, context);\n }\n\n // emit(event: string, data: ShuffleEventData): this {\n emit<EventName extends keyof ShuffleEventMap>(event: EventName, data: ShuffleEventMap[EventName]): this {\n if (this.isDestroyed) {\n return this;\n }\n return super.emit(event, data);\n }\n\n off(event: string, callback?: ShuffleEventCallback): this {\n return super.off(event, callback);\n }\n\n /**\n * Retrieve an element from an option.\n * @param option The option to check.\n * @return The plain element or null.\n */\n #getElementOption(option?: ElementOption | null): HTMLElement | null {\n // If column width is a string, treat is as a selector and search for the\n // sizer element within the outermost container\n if (typeof option === 'string') {\n return this.element ? this.element.querySelector(option) : document.querySelector(option);\n }\n\n // Check for an element\n if (option && 'nodeType' in option && option.nodeType && option.nodeType === 1) {\n return option as HTMLElement;\n }\n\n // Check for jQuery object\n if (option && 'jquery' in option) {\n return option[0];\n }\n\n return null;\n }\n\n /**\n * Ensures the shuffle container has the css styles it needs applied to it.\n * @param styles Key value pairs for position and overflow.\n */\n #validateStyles(styles: CSSStyleDeclaration): void {\n // Position cannot be static.\n if (styles.position === 'static') {\n this.element.style.position = 'relative';\n }\n\n // Overflow has to be hidden.\n if (styles.overflow !== 'hidden') {\n this.element.style.overflow = 'hidden';\n }\n }\n\n /**\n * Filter the elements by a category.\n * @param category Category to filter by. If it's given, the last category will be used to filter the items.\n * @param collection Optionally filter a collection. Defaults to all the items.\n * @return Object with visible and hidden arrays.\n */\n #filter(category: FilterArg = this.lastFilter, collection: ShuffleItem[] = this.items): FilterSet {\n const set = this.#getFilteredSets(category, collection);\n\n // Individually add/remove hidden/visible classes\n toggleFilterClasses(set);\n\n // Save the last filter in case elements are appended.\n this.lastFilter = category;\n\n // This is saved mainly because providing a filter function (like searching)\n // will overwrite the `lastFilter` property every time its called.\n if (typeof category === 'string') {\n this.group = category;\n }\n\n return set;\n }\n\n /**\n * Returns an object containing the visible and hidden elements.\n * @param category Category or function to filter by.\n * @param items A collection of items to filter.\n */\n #getFilteredSets(category: FilterArg, items: ShuffleItem[]): FilterSet {\n let visible: ShuffleItem[] = [];\n const hidden: ShuffleItem[] = [];\n\n // category === 'all', add visible class to everything\n if (category === Shuffle.ALL_ITEMS) {\n visible = items;\n\n // Loop through each item and use provided function to determine\n // whether to hide it or not.\n } else {\n for (const item of items) {\n if (this.#doesPassFilter(category, item.element)) {\n visible.push(item);\n } else {\n hidden.push(item);\n }\n }\n }\n\n return {\n visible,\n hidden,\n };\n }\n\n /**\n * Test an item to see if it passes a category.\n * @param category Category or function to filter by.\n * @param element An element to test.\n * @return Whether it passes the category/filter.\n */\n #doesPassFilter(category: FilterArg, element: HTMLElement): boolean {\n if (typeof category === 'function') {\n return category.call(element, element, this);\n }\n\n // Check each element's data-groups attribute against the given category.\n const attr = element.dataset[Shuffle.FILTER_ATTRIBUTE_KEY] ?? '';\n const keys = this.options.delimiter ? attr.split(this.options.delimiter) : (JSON.parse(attr) as string[]);\n\n function testCategory(category: string): boolean {\n return keys.includes(category);\n }\n\n if (Array.isArray(category)) {\n if (this.options.filterMode === FilterMode.ANY) {\n return category.some(testCategory);\n }\n return category.every(testCategory);\n }\n\n return keys.includes(category);\n }\n\n /**\n * Updates the visible item count.\n */\n #updateItemCount(): void {\n this.visibleItems = this.#getFilteredItems().length;\n }\n\n /**\n * Sets css transform transition on a group of elements. This is not executed\n * at the same time as `item.init` so that transitions don't occur upon\n * initialization of a new Shuffle instance.\n * @param items Shuffle items to set transitions on.\n * @protected\n */\n setItemTransitions(items: ShuffleItem[]): void {\n const { speed, easing } = this.options;\n const positionProps = this.options.useTransforms ? ['transform'] : ['top', 'left'];\n\n // Allow users to transition other properties if they exist in the `before`\n // css mapping of the shuffle item.\n const cssProps = Object.keys(ShuffleItem.Css.HIDDEN.before);\n const properties = [...positionProps, ...cssProps].join(',');\n\n for (const item of items) {\n item.element.style.transitionDuration = `${speed}ms`;\n // @ts-expect-error TS doesn't like that we are assigning `undefined`, but\n // browsers don't have a problem with it.\n item.element.style.transitionTimingFunction = easing;\n item.element.style.transitionProperty = properties;\n }\n }\n\n #getItems(): ShuffleItem[] {\n return (\n // oxlint-disable-next-line unicorn/prefer-spread `children` doesn't have `Symbol.iterator`\n Array.from(this.element.children)\n .filter((el) => el.matches(this.options.itemSelector!))\n .map((el) => new ShuffleItem(el as HTMLElement, this.options.isRTL))\n );\n }\n\n /**\n * Combine the current items array with a new one and sort it by DOM order.\n * @param items Items to track.\n */\n #mergeNewItems(items: ShuffleItem[]): ShuffleItem[] {\n // oxlint-disable-next-line unicorn/prefer-spread `children` doesn't have `Symbol.iterator`\n const children = Array.from(this.element.children);\n return sorter([...this.items, ...items], {\n by(element) {\n return children.indexOf(element);\n },\n });\n }\n\n #getFilteredItems(): ShuffleItem[] {\n return this.items.filter((item) => item.isVisible);\n }\n\n #getConcealedItems(): ShuffleItem[] {\n return this.items.filter((item) => !item.isVisible);\n }\n\n /**\n * Returns the column size, based on column width and sizer options.\n * @param containerWidth Size of the parent container.\n * @param gutterSize Size of the gutters.\n */\n #getColumnSize(containerWidth: number, gutterSize: number): number {\n let size;\n\n // If the columnWidth property is a function, then the grid is fluid\n if (typeof this.options.columnWidth === 'function') {\n size = this.options.columnWidth(containerWidth);\n\n // columnWidth option isn't a function, are they using a sizing element?\n } else if (this.sizer) {\n size = Shuffle.getSize(this.sizer).width;\n\n // if not, how about the explicitly set option?\n } else if (this.options.columnWidth) {\n size = this.options.columnWidth;\n\n // or use the size of the first item\n } else if (this.items.length > 0) {\n size = Shuffle.getSize(this.items[0].element, true).width;\n\n // if there's no items, use size of container\n } else {\n size = containerWidth;\n }\n\n // Don't let them set a column width of zero.\n if (size === 0) {\n size = containerWidth;\n }\n\n return size + gutterSize;\n }\n\n /**\n * Returns the gutter size, based on gutter width and sizer options.\n * @param containerWidth Size of the parent container.\n */\n #getGutterSize(containerWidth: number): number {\n if (typeof this.options.gutterWidth === 'function') {\n return this.options.gutterWidth(containerWidth);\n }\n\n if (this.sizer) {\n return getNumberStyle(this.sizer, 'marginLeft');\n }\n\n if (this.options.gutterWidth) {\n return this.options.gutterWidth;\n }\n\n return 0;\n }\n\n /**\n * Calculate the number of columns to be used. Gets css if using sizer element.\n * @param containerWidth Optionally specify a container width if it's already available.\n */\n #setColumns(containerWidth: number = Shuffle.getSize(this.element).width): void {\n const gutter = this.#getGutterSize(containerWidth);\n const columnWidth = this.#getColumnSize(containerWidth, gutter);\n let calculatedColumns = (containerWidth + gutter) / columnWidth;\n // TypeScript doesn't like comparing undefined to a number, but JS allows it\n // and always returns `false`.\n const threshold = this.options.columnThreshold!;\n\n // Widths given from getStyles are not precise enough...\n if (Math.abs(Math.round(calculatedColumns) - calculatedColumns) < threshold) {\n // e.g. calculatedColumns = 11.998876\n calculatedColumns = Math.round(calculatedColumns);\n }\n\n this.cols = Math.max(Math.floor(calculatedColumns || 0), 1);\n this.containerWidth = containerWidth;\n this.colWidth = columnWidth;\n }\n\n /**\n * Adjust the height of the grid\n */\n #setContainerSize(): void {\n this.element.style.height = `${this.#getContainerSize()}px`;\n }\n\n /**\n * Based on the column heights, it returns the biggest one.\n */\n #getContainerSize(): number {\n return Math.max(...this.positions);\n }\n\n /**\n * Get the clamped stagger amount.\n * @param index Index of the item to be staggered.\n */\n #getStaggerAmount(index: number): number {\n return Math.min(index * this.options.staggerAmount!, this.options.staggerAmountMax!);\n }\n\n /**\n * Zeros out the y columns array, which is used to determine item placement.\n */\n #resetCols(): void {\n let i = this.cols;\n this.positions = [];\n while (i) {\n i -= 1;\n this.positions.push(0);\n }\n }\n\n /**\n * Loops through each item that should be shown and calculates the x, y position.\n * @param items Array of items that will be shown/layed out in order in their array.\n */\n #layout(items: ShuffleItem[]): void {\n const itemPositions = this.#getNextPositions(items);\n\n let count = 0;\n for (let i = 0; i < items.length; i += 1) {\n const item = items[i];\n const callback = () => {\n item.applyCss(ShuffleItem.Css.VISIBLE.after);\n };\n\n // If the item will not change its position, do not add it to the render\n // queue. Transitions don't fire when setting a property to the same value.\n if (Point.equals(item.point, itemPositions[i]) && !item.isHidden) {\n item.applyCss(ShuffleItem.Css.VISIBLE.before);\n callback();\n continue;\n }\n\n item.point = itemPositions[i];\n item.scale = ShuffleItem.Scale.VISIBLE;\n item.isHidden = false;\n\n // Clone the object so that the `before` object isn't modified when the\n // transition delay is added.\n const styles = this.getStylesForTransition(item, ShuffleItem.Css.VISIBLE.before);\n styles.transitionDelay = `${this.#getStaggerAmount(count)}ms`;\n\n this.#queue.push({\n item,\n styles,\n callback,\n });\n\n count += 1;\n }\n }\n\n /**\n * Return an array of Point instances representing the future positions of\n * each item.\n * @param items Array of sorted shuffle items.\n */\n #getNextPositions(items: ShuffleItem[]): Point[] {\n // If position data is going to be changed, add the item's size to the\n // transformer to allow for calculations.\n if (this.options.isCentered) {\n const itemsData = items.map((item, i) => {\n const itemSize = Shuffle.getSize(item.element, true);\n const point = this.#getItemPosition(itemSize);\n return new Rect(point.x, point.y, itemSize.width, itemSize.height, i);\n });\n\n return this.getTransformedPositions(itemsData, this.containerWidth);\n }\n\n // If no transforms are going to happen, simply return an array of the\n // future points of each item.\n return items.map((item) => this.#getItemPosition(Shuffle.getSize(item.element, true)));\n }\n\n /**\n * Determine the location of the next item, based on its size.\n * @param itemSize Object with width and height.\n */\n #getItemPosition(itemSize: { width: number; height: number }): Point {\n return getItemPosition({\n