diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..45b8552 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,35 @@ +name: Lint + +on: + push: + branches: + - main + - develop + pull_request: + branches: + - main + - develop + +jobs: + run-linters: + name: Run linters + runs-on: ubuntu-latest + + steps: + - name: Check out Git repository + uses: actions/checkout@v3 + + - name: Set up Node.js + uses: actions/setup-node@v3 + with: + node-version: 16 + cache: 'yarn' + + - name: Setup yarn + run: npm install -g yarn + + - name: Install dependencies + run: yarn install --frozen-lockfile + + - name: Run linte + run: yarn lint diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..118e66e --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,35 @@ +name: Test + +on: + push: + branches: + - main + - develop + pull_request: + branches: + - main + - develop + +jobs: + test: + name: Run Tests + runs-on: ubuntu-latest + + steps: + - name: Check out Git repository + uses: actions/checkout@v3 + + - name: Set up Node.js + uses: actions/setup-node@v3 + with: + node-version: 16 + cache: 'yarn' + + - name: Setup yarn + run: npm install -g yarn + + - name: Install dependencies + run: yarn install --frozen-lockfile + + - name: Run tests + run: yarn test diff --git a/.prettierignore b/.prettierignore index 3897265..f4f9385 100644 --- a/.prettierignore +++ b/.prettierignore @@ -6,6 +6,7 @@ node_modules .env .env.* !.env.example +/coverage # Ignore files for PNPM, NPM and YARN pnpm-lock.yaml diff --git a/README.md b/README.md index 4a63bc1..0a13735 100644 --- a/README.md +++ b/README.md @@ -30,24 +30,77 @@ pnpm install svelte-grid-extended ## Props -List of all available props: - -| prop | description | type | default | -| -------- | ---------------------------------------------------------------------------------- | ----------------------------------------------------------------- | -------- | -| cols | Grid columns count. If set to 0, grid will grow infinitly. Must be >= 0. | number | 0 | -| rows | Grid rows count. If set to 0, grid will grow infinitly. Must be >= 0. | number | 0 | -| itemSize | Size of the grid item. If not set, grid will calculate it based on container size. | { width?: number, height?: number } | {} | -| items | Array of grid items. | Array<{ id: string, x: number, y: number, w: number, h: number }> | requried | -| gap | Gap between grid items. | number | 10 | -| bounds | Should grid items be bounded by the grid container. | boolean | false | +### Main props + +| prop | description | type | default | +| -------- | ---------------------------------------------------------------------------------- | ----------------------------------- | -------- | +| cols | Grid columns count. If set to 0, grid will grow infinitly. Must be >= 0. | number | 0 | +| rows | Grid rows count. If set to 0, grid will grow infinitly. Must be >= 0. | number | 0 | +| itemSize | Size of the grid item. If not set, grid will calculate it based on container size. | { width?: number, height?: number } | {} | +| items | Array of grid items. | [Layout\](#layout-type) | requried | +| gap | Gap between grid items. | number | 10 | +| bounds | Should grid items be bounded by the grid container. | boolean | false | +| readonly | If true disables interaction with grid items. | boolean | false | > ⚠️ if `cols` or/and `rows` are set to 0, `itemSize.width` or/and `itemSize.height` must be setted. +### Layout + +`Layout` are represented as an array of objects, items of which must have the following properties: + +| prop | description | type | default | +| --------- | ------------------------------------------------------------------- | ------- | --------- | +| id | Unique id of the item. Used to compare items during collision tests | string | requried | +| x | X position of the item in grid units. | number | requried | +| y | Y position of the item in grid units. | number | requried | +| w | Width of the item in grid units. | number | requried | +| h | Height of the item in grid units. | number | requried | +| movable | If true, item can be moved by user. | boolean | true | +| resizable | If true, item can be resized by user. | boolean | true | +| data | Custom attributes. 🦌 | T | undefined | + +### Style related props: + +Component can be styled with css framework of your choice or with global classes. To do so, you can use the following props: + +- `class` - class name for grid container. +- `itemClass` - class name for grid item. +- `itemActiveClass` - class name that applies when item is currently being dragged or resized. By default, it is used to make active grid item transparent. +- `itemPreviewClass` - class name for preview where item will be placed after interaction. +- `resizerClass` - class name for item's resize handle. + +To understand how to use these props, look at `` component simplified structure. + +> 📄 `active` is variable that indicates if grid item is currently being dragged or resized: + +```svelte + +
+ +
+ + +
+ +
+ + {#if active} + +
+ {/if} + + +
+ +``` + ## Usage - [Basic](#basic) - [Static grid](#static-grid) - [Grid without bounds](#grid-without-bounds) +- [Styling](#styling) +- [Disable interactions](#disable-interactions) ### Basic @@ -138,3 +191,92 @@ It can be set to both dimensions or just one.
Content
``` + +### Styling + +Grid can be styled with classes passed to various props. Check [Style related props](#style-related-props) section for more info. + +✨ [repl](https://svelte.dev/repl/b158b6fbb2234241b7ea9737b7e2fc24?version=3.53.1) + +```svelte + + + +
Content
+
+ + +``` + +### Disable interactions + +To disable interactions, set `readOnly` prop to `true`. Or set `movable` and/or `resizable` to `false` on specific item. + +Read Only grid: ✨ [repl](https://svelte.dev/repl/29ce85a23a714c51b6638f12f5ecdd7c?version=3.53.1) + +```svelte + + + +
Content
+
+``` + +Make item non-interactive: ✨ [repl](https://svelte.dev/repl/1b3b9b9b9b9b9b9b9b9b9b9b9b9b9b9b?version=3.53.1) + +```svelte + + + +
Content
+
+``` diff --git a/package.json b/package.json index 14e6a32..c898e13 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "svelte-grid-extended", - "version": "0.0.3", + "version": "0.1.0", "description": "A draggable and resizable grid layout, for Svelte", "repository": "https://github.com/cuire/svelte-grid-extended", "scripts": { diff --git a/src/lib/Grid.svelte b/src/lib/Grid.svelte index 3c83448..b8f7e97 100644 --- a/src/lib/Grid.svelte +++ b/src/lib/Grid.svelte @@ -6,7 +6,7 @@ import { findGridSize } from './utils/breakpoints'; import { getGridDimensions } from './utils/grid'; - import type { Breakpoints, ItemSize, GridSize, Item } from './types'; + import type { Breakpoints, ItemSize, GridSize, LayoutItem } from './types'; export let cols: GridSize = 0; @@ -16,7 +16,16 @@ export let gap = 10; - export let items: Item[]; + type T = $$Generic; + + interface $$Slots { + default: { + item: LayoutItem; + }; + loader: Record; + } + + export let items: LayoutItem[]; export let breakpoints: Breakpoints = { xxl: 1536, @@ -31,6 +40,8 @@ export let bounds = false; + export let readOnly = false; + export let debug = false; let classes = ''; @@ -67,7 +78,7 @@ $: if (typeof rows === 'number') _rows = rows; - $: if (itemSize?.width && itemSize?.height) _itemSize = itemSize as ItemSize; + $: if (itemSize?.width && itemSize?.height) _itemSize = { ...itemSize } as ItemSize; $: if (itemSize?.width && _itemSize?.width) containerWidth = _cols * (_itemSize.width + gap + 1); @@ -92,7 +103,7 @@ items = [...items]; } - function updateGridDimensions(event: CustomEvent<{ item: Item }>) { + function updateGridDimensions(event: CustomEvent<{ item: LayoutItem }>) { const { item } = event.detail; calculatedGridSize = getGridDimensions([...items.filter((i) => i.id !== item.id), item]); } @@ -108,7 +119,7 @@ const height = entry.contentRect.height; _cols = findGridSize(cols, width, breakpoints); - _rows = findGridSize(rows, width, breakpoints); + _rows = findGridSize(rows, height, breakpoints); shouldExpandCols = _cols === 0; shouldExpandRows = _rows === 0; @@ -142,7 +153,8 @@ maxCols, maxRows, bounds, - items + items, + readOnly }} activeClass={itemActiveClass} previewClass={itemPreviewClass} diff --git a/src/lib/GridItem.svelte b/src/lib/GridItem.svelte index f8b1d0d..b9c27f7 100644 --- a/src/lib/GridItem.svelte +++ b/src/lib/GridItem.svelte @@ -6,14 +6,14 @@ import { coordinate2size, calcPosition, snapOnMove, snapOnResize } from './utils/item'; import { hasCollisions } from './utils/grid'; - import type { Item, ItemSize, ItemPosition, GridParams } from './types'; + import type { LayoutItem, ItemSize, ItemPosition, GridParams } from './types'; const dispatch = createEventDispatcher<{ - itemchange: { item: Item }; - previewchange: { item: Item }; + itemchange: { item: LayoutItem }; + previewchange: { item: LayoutItem }; }>(); - export let item: Item; + export let item: LayoutItem; export let gridParams: GridParams; @@ -29,10 +29,24 @@ let active = false; - $: ({ left, top, width, height } = calcPosition(item, { - itemSize: gridParams.itemSize, - gap: gridParams.gap - })); + let left: number; + + let top: number; + + let width: number; + + let height: number; + + $: if (!active) { + const newPosition = calcPosition(item, { + itemSize: gridParams.itemSize, + gap: gridParams.gap + }); + left = newPosition.left; + top = newPosition.top; + width = newPosition.width; + height = newPosition.height; + } let min: ItemSize; @@ -53,10 +67,27 @@ }; } - let previewItem: Item = item; + let previewItem: LayoutItem = item; $: previewItem, dispatch('previewchange', { item: previewItem }); + const movable = !gridParams.readOnly && item.movable === undefined && item.movable !== false; + + const resizable = + !gridParams.readOnly && item.resizable === undefined && item.resizable !== false; + + const moveAction = movable + ? move + : () => { + // do nothing + }; + + const resizeAction = resizable + ? resize + : () => { + // do nothing + }; + function start() { active = true; } @@ -105,16 +136,16 @@ class={`${classes} ${active ? activeClass : ''}`} class:item-default={!classes} class:active-default={!activeClass && active} - use:move={{ position: { left, top } }} + use:moveAction={{ position: { left, top } }} on:movestart={start} on:moving={moving} on:moveend={end} - use:resize={{ min, max, resizerClass, bounds: gridParams.bounds }} + use:resizeAction={{ min, max, resizerClass, bounds: gridParams.bounds }} on:resizestart={start} on:resizing={resizing} on:resizeend={end} style={`position: absolute; left:${left}px; top:${top}px; width: ${width}px; height: ${height}px; - cursor: move; touch-action: none; user-select: none;`} + ${movable ? 'cursor: move;' : ''} touch-action: none; user-select: none;`} >
diff --git a/src/lib/index.ts b/src/lib/index.ts index 41f6559..138df90 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -1,3 +1,5 @@ import Grid from './Grid.svelte'; +import type { Layout, LayoutItem } from './types'; +export { Grid, type Layout, type LayoutItem }; export default Grid; diff --git a/src/lib/types.ts b/src/lib/types.ts index 8271d01..0d72b1e 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1,11 +1,15 @@ import type { RequireAtLeastOne } from '$lib/utils/types'; -export type Item = Size & +export type LayoutItem = Size & Position & { id: string; min?: Size; max?: Size; - }; + movable?: boolean; + resizable?: boolean; + } & (T extends undefined ? { data: T } : { data?: T }); + +export type Layout = LayoutItem[]; export type Size = { w: number; h: number }; @@ -31,5 +35,6 @@ export type GridParams = { maxCols: number; maxRows: number; bounds: boolean; - items: Item[]; + items: LayoutItem[]; + readOnly: boolean; }; diff --git a/src/lib/utils/grid.ts b/src/lib/utils/grid.ts index 0bf4163..0b40525 100644 --- a/src/lib/utils/grid.ts +++ b/src/lib/utils/grid.ts @@ -1,6 +1,6 @@ -import type { GridDimensions, Item } from '$lib/types'; +import type { GridDimensions, LayoutItem } from '$lib/types'; -export function isItemColliding(item: Item, otherItem: Item): boolean { +export function isItemColliding(item: LayoutItem, otherItem: LayoutItem): boolean { return ( item.id !== otherItem.id && item.x <= otherItem.x + otherItem.w - 1 && @@ -10,15 +10,15 @@ export function isItemColliding(item: Item, otherItem: Item): boolean { ); } -export function getCollisions(currentItem: Item, items: Item[]): Item[] { +export function getCollisions(currentItem: LayoutItem, items: LayoutItem[]): LayoutItem[] { return items.filter((item) => isItemColliding(currentItem, item)); } -export function hasCollisions(currentItem: Item, items: Item[]): boolean { +export function hasCollisions(currentItem: LayoutItem, items: LayoutItem[]): boolean { return items.some((item) => isItemColliding(currentItem, item)); } -export function getGridDimensions(items: Item[]): GridDimensions { +export function getGridDimensions(items: LayoutItem[]): GridDimensions { const cols = Math.max(...items.map((item) => item.x + item.w), 1); const rows = Math.max(...items.map((item) => item.y + item.h), 1); diff --git a/src/lib/utils/item.ts b/src/lib/utils/item.ts index ed2081f..9d80923 100644 --- a/src/lib/utils/item.ts +++ b/src/lib/utils/item.ts @@ -1,4 +1,4 @@ -import type { GridParams, Item, ItemPosition, ItemSize, Position, Size } from '$lib/types'; +import type { GridParams, LayoutItem, ItemPosition, ItemSize, Position, Size } from '$lib/types'; export function coordinate2position(coordinate: number, cellSize: number, gap: number): number { return coordinate * cellSize + (coordinate + 1) * gap; @@ -19,7 +19,7 @@ export function size2coordinate(size: number, cellSize: number, gap: number): nu export function snapOnMove( left: number, top: number, - item: Item, + item: LayoutItem, gridParams: GridParams ): Position { const { itemSize, gap } = gridParams; @@ -37,7 +37,7 @@ export function snapOnMove( export function snapOnResize( width: number, height: number, - item: Item, + item: LayoutItem, gridParams: GridParams ): Size { const { itemSize, gap } = gridParams; @@ -53,7 +53,7 @@ export function snapOnResize( } export function calcPosition( - item: Item, + item: LayoutItem, options: { itemSize: ItemSize; gap: number } ): ItemPosition & ItemSize { const { itemSize, gap } = options; diff --git a/src/routes/examples/custom-attributes/+page.svelte b/src/routes/examples/custom-attributes/+page.svelte new file mode 100644 index 0000000..fd1cef6 --- /dev/null +++ b/src/routes/examples/custom-attributes/+page.svelte @@ -0,0 +1,29 @@ + + + +
{item.data?.text}
+
+ + diff --git a/src/routes/examples/disable-moving-or-resizing/+page.svelte b/src/routes/examples/disable-moving-or-resizing/+page.svelte new file mode 100644 index 0000000..11c2027 --- /dev/null +++ b/src/routes/examples/disable-moving-or-resizing/+page.svelte @@ -0,0 +1,33 @@ + + + +
+ {item.id} + {item.movable === false ? 'Cant move' : ''} + {item.resizable === false ? 'Cant resize' : ''} +
+
+ + diff --git a/src/routes/examples/readonly/+page.svelte b/src/routes/examples/readonly/+page.svelte new file mode 100644 index 0000000..54b055b --- /dev/null +++ b/src/routes/examples/readonly/+page.svelte @@ -0,0 +1,29 @@ + + + +
{item.id}
+
+ + diff --git a/src/routes/examples/styling/+page.svelte b/src/routes/examples/styling/+page.svelte new file mode 100644 index 0000000..1977485 --- /dev/null +++ b/src/routes/examples/styling/+page.svelte @@ -0,0 +1,60 @@ + + + +
{item.id}
+
+ + diff --git a/tests/unit/grid.test.ts b/tests/unit/grid.test.ts index ad7fabb..9626afb 100644 --- a/tests/unit/grid.test.ts +++ b/tests/unit/grid.test.ts @@ -7,7 +7,7 @@ import { isItemColliding } from '../../src/lib/utils/grid'; -import type { Item } from '../../src/lib/types'; +import type { LayoutItem } from '../../src/lib/types'; /** * Grid with shape:\ @@ -17,7 +17,7 @@ import type { Item } from '../../src/lib/types'; * | 7 ~ ~ ~ |\ * Where ~ is empty spot */ -const items: Item[] = [ +const items: LayoutItem[] = [ { id: '0', x: 0, y: 0, w: 1, h: 1 }, { id: '1', x: 1, y: 0, w: 1, h: 1 }, { id: '2', x: 2, y: 0, w: 1, h: 1 }, diff --git a/tests/unit/item.test.ts b/tests/unit/item.test.ts index ccac547..27eb4ff 100644 --- a/tests/unit/item.test.ts +++ b/tests/unit/item.test.ts @@ -11,7 +11,7 @@ import { snapOnResize } from '../../src/lib/utils/item'; -import type { GridParams, Item, ItemSize, Position, Size } from '../../src/lib/types'; +import type { GridParams, LayoutItem, ItemSize, Position, Size } from '../../src/lib/types'; describe('🥐 coordinate2position()', () => { test.each([ @@ -160,8 +160,8 @@ const gridParams: GridParams = { }; describe('🥥 snapOnMove()', () => { - const item1x1: Item = { id: '0', x: 0, y: 0, w: 1, h: 1 }; - const item4x4: Item = { id: '0', x: 0, y: 0, w: 4, h: 4 }; + const item1x1: LayoutItem = { id: '0', x: 0, y: 0, w: 1, h: 1 }; + const item4x4: LayoutItem = { id: '0', x: 0, y: 0, w: 4, h: 4 }; test.each([ [0, 0, item1x1, gridParams, { x: 0, y: 0 }], @@ -182,7 +182,7 @@ describe('🥥 snapOnMove()', () => { [600, 600, item1x1, gridParams, { x: 6, y: 6 }] ])( 'should find the correct position %dx%d for item 1x1', - (left: number, top: number, item: Item, gridParams: GridParams, expected: Position) => { + (left: number, top: number, item: LayoutItem, gridParams: GridParams, expected: Position) => { expect(snapOnMove(left, top, item, gridParams)).toEqual(expected); } ); @@ -206,15 +206,15 @@ describe('🥥 snapOnMove()', () => { [600, 600, item4x4, gridParams, { x: 4, y: 4 }] ])( 'should find the correct position %dx%d for item4x4', - (left: number, top: number, item: Item, gridParams: GridParams, expected: Position) => { + (left: number, top: number, item: LayoutItem, gridParams: GridParams, expected: Position) => { expect(snapOnMove(left, top, item, gridParams)).toEqual(expected); } ); }); describe('🍍 snapOnResize()', () => { - const itemX1Y1: Item = { id: '0', x: 1, y: 1, w: 1, h: 1 }; - const itemX4Y4: Item = { id: '0', x: 4, y: 4, w: 1, h: 1 }; + const itemX1Y1: LayoutItem = { id: '0', x: 1, y: 1, w: 1, h: 1 }; + const itemX4Y4: LayoutItem = { id: '0', x: 4, y: 4, w: 1, h: 1 }; test.each([ [100, 100, itemX1Y1, gridParams, { w: 1, h: 1 }], @@ -235,7 +235,7 @@ describe('🍍 snapOnResize()', () => { [600, 600, itemX1Y1, gridParams, { w: 6, h: 6 }] ])( 'should find the correct size %dx%d for itemX1Y1', - (width: number, height: number, item: Item, gridParams: GridParams, expected: Size) => { + (width: number, height: number, item: LayoutItem, gridParams: GridParams, expected: Size) => { expect(snapOnResize(width, height, item, gridParams)).toEqual(expected); } ); @@ -259,14 +259,14 @@ describe('🍍 snapOnResize()', () => { [1200, 1200, itemX4Y4, gridParams, { w: 4, h: 4 }] ])( 'should find the correct size %dx%d for itemX4Y4', - (width: number, height: number, item: Item, gridParams: GridParams, expected: Size) => { + (width: number, height: number, item: LayoutItem, gridParams: GridParams, expected: Size) => { expect(snapOnResize(width, height, item, gridParams)).toEqual(expected); } ); }); describe('🫑 calcPosition()', () => { - const item: Item = { id: '0', x: 1, y: 1, w: 1, h: 1 }; + const item: LayoutItem = { id: '0', x: 1, y: 1, w: 1, h: 1 }; const itemSize: ItemSize = { width: 100, height: 100 }; const gap = 0;