From 2d0b1916976be422536d6bf3436e931167b6019b Mon Sep 17 00:00:00 2001 From: Eric Kwoka <43540491+ekwoka@users.noreply.github.com> Date: Sun, 25 Aug 2024 22:07:47 +0400 Subject: [PATCH] :sparkles: Adds Sort Plugin (#58) --- package.json | 2 +- packages/alpinets/src/types.ts | 1 + packages/sort/package.json | 42 ++++++++ packages/sort/src/index.ts | 186 +++++++++++++++++++++++++++++++++ packages/sort/tsconfig.json | 5 + packages/sort/vite.config.ts | 52 +++++++++ pnpm-lock.yaml | 30 ++++++ size.json | 10 ++ 8 files changed, 327 insertions(+), 1 deletion(-) create mode 100644 packages/sort/package.json create mode 100644 packages/sort/src/index.ts create mode 100644 packages/sort/tsconfig.json create mode 100644 packages/sort/vite.config.ts diff --git a/package.json b/package.json index cfc0466..21e0515 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "alpinejs", - "version": "3.13.0", + "version": "3.14.1", "type": "module", "exports": { ".": { diff --git a/packages/alpinets/src/types.ts b/packages/alpinets/src/types.ts index f5650c6..fcc9106 100644 --- a/packages/alpinets/src/types.ts +++ b/packages/alpinets/src/types.ts @@ -57,6 +57,7 @@ export interface XAttributes { }; _x_hideChildren: ElementWithXAttributes[]; _x_inlineBindings: Record; + _x_sort_key: string; } type Binding = { diff --git a/packages/sort/package.json b/packages/sort/package.json new file mode 100644 index 0000000..2701bbc --- /dev/null +++ b/packages/sort/package.json @@ -0,0 +1,42 @@ +{ + "name": "@alpinets/sort", + "version": "0.0.1", + "description": "The rugged, minimal TypeScript framework", + "author": "Eric Kwoka (https://thekwoka.net/)", + "license": "MIT", + "type": "module", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "./src": { + "import": "./src/index.ts" + }, + "./package.json": "./package.json" + }, + "module": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist", + "src" + ], + "scripts": { + "build": "vite build", + "coverage": "vitest run --coverage", + "lint:types": "tsc --noEmit", + "prebuild": "rm -rf dist", + "test": "vitest" + }, + "dependencies": { + "sortablejs": "1.15.2" + }, + "peerDependencies": { + "@alpinets/alpinets": "workspace:^" + }, + "devDependencies": { + "@types/sortablejs": "1.15.8", + "vite": "5.3.5", + "vitest": "2.0.5" + } +} diff --git a/packages/sort/src/index.ts b/packages/sort/src/index.ts new file mode 100644 index 0000000..44cf5e4 --- /dev/null +++ b/packages/sort/src/index.ts @@ -0,0 +1,186 @@ +import type { PluginCallback } from '@alpinets/alpinets'; +import Alpine from '@alpinets/alpinets'; +import { ElementWithXAttributes } from '@alpinets/alpinets'; +import { Utilities } from '@alpinets/alpinets/src/types'; +// @ts-expect-error +import Sortable from 'sortablejs'; + +export const Sort: PluginCallback = (Alpine) => { + Alpine.directive( + 'sort', + ( + el, + { value, modifiers, expression }, + { evaluate, evaluateLater, cleanup }, + ) => { + if (value === 'config') { + return; // This will get handled by the main directive... + } + + if (value === 'handle') { + return; // This will get handled by the main directive... + } + + if (value === 'group') { + return; // This will get handled by the main directive... + } + + // Supporting both `x-sort:item` AND `x-sort:key` (key for BC)... + if (value === 'key' || value === 'item') { + if ([undefined, null, ''].includes(expression)) return; + + el._x_sort_key = evaluate(expression); + + return; + } + + const preferences = { + hideGhost: !modifiers.includes('ghost'), + useHandles: !!el.querySelector('[x-sort\\:handle]'), + group: getGroupName(el, modifiers), + }; + + const handleSort = generateSortHandler(expression, evaluateLater); + + const config = getConfigurationOverrides(el, modifiers, evaluate); + + const sortable = initSortable( + el, + config, + preferences, + (key, position) => { + handleSort(key, position); + }, + ); + + cleanup(() => sortable.destroy()); + }, + ); +}; + +function generateSortHandler( + expression: string, + evaluateLater: Utilities['evaluateLater'], +) { + // No handler was passed to x-sort... + // biome-ignore lint/suspicious/noEmptyBlockStatements: Intentional No-op + if ([undefined, null, ''].includes(expression)) return () => {}; + + const handle = evaluateLater(expression); + + return (key: string, position: number) => { + // In the case of `x-sort="handleSort"`, let us call it manually... + Alpine.dontAutoEvaluateFunctions(() => { + handle( + // If a function is returned, call it with the key/position params... + (received) => { + if (typeof received === 'function') received(key, position); + }, + // Provide $key and $position to the scope in case they want to call their own function... + { + scope: { + // Supporting both `$item` AND `$key` ($key for BC)... + $key: key, + $item: key, + $position: position, + }, + }, + ); + }); + }; +} + +function getConfigurationOverrides( + el: ElementWithXAttributes, + _modifiers: string[], + evaluate: Utilities['evaluate'], +) { + return el.hasAttribute('x-sort:config') + ? evaluate(el.getAttribute('x-sort:config')) + : {}; +} + +function initSortable(el, config, preferences, handle) { + let ghostRef; + + const options = { + animation: 150, + + handle: preferences.useHandles ? '[x-sort\\:handle]' : null, + + group: preferences.group, + + filter(e) { + // Normally, we would just filter out any elements without `[x-sort:item]` + // on them, however for backwards compatibility (when we didn't require + // `[x-sort:item]`) we will check for x-sort\\:item being used at all + if (!el.querySelector('[x-sort\\:item]')) return false; + + const itemHasAttribute = e.target.closest('[x-sort\\:item]'); + + return itemHasAttribute ? false : true; + }, + + onSort(e) { + // If item has been dragged between groups... + if (e.from !== e.to) { + // And this is the group it was dragged FROM... + if (e.to !== e.target) { + return; // Don't do anything, because the other group will call the handler... + } + } + + const key = e.item._x_sort_key; + const position = e.newIndex; + + if (key !== undefined || key !== null) { + handle(key, position); + } + }, + + onStart() { + document.body.classList.add('sorting'); + + ghostRef = document.querySelector('.sortable-ghost'); + + if (preferences.hideGhost && ghostRef) ghostRef.style.opacity = '0'; + }, + + onEnd() { + document.body.classList.remove('sorting'); + + if (preferences.hideGhost && ghostRef) ghostRef.style.opacity = '1'; + + ghostRef = undefined; + + keepElementsWithinMorphMarkers(el); + }, + }; + + return new Sortable(el, { ...options, ...config }); +} + +function keepElementsWithinMorphMarkers(el) { + let cursor = el.firstChild; + + while (cursor.nextSibling) { + if (cursor.textContent.trim() === '[if ENDBLOCK]> +import { resolve } from 'node:path'; +import { defineConfig } from 'vite'; +import dts from 'vite-plugin-dts'; +import ExternalDeps from 'vite-plugin-external-deps'; +import WorkspaceSource from 'vite-plugin-workspace-source'; +import tsconfigPaths from 'vite-tsconfig-paths'; + +export default defineConfig({ + root: resolve(__dirname), + plugins: [ + dts({ + entryRoot: resolve(__dirname, 'src'), + tsconfigPath: resolve(__dirname, 'tsconfig.json'), + }), + tsconfigPaths(), + ExternalDeps(), + WorkspaceSource(), + ], + define: { + 'import.meta.vitest': 'undefined', + 'import.meta.DEBUG': 'false', + }, + build: { + target: 'esnext', + outDir: resolve(__dirname, 'dist'), + lib: { + entry: resolve(__dirname, 'src', 'index.ts'), + formats: ['es'], + }, + minify: false, + rollupOptions: { + output: { + preserveModules: true, + preserveModulesRoot: 'src', + entryFileNames: ({ name: fileName }) => { + return `${fileName}.js`; + }, + sourcemap: true, + }, + external: [/node_modules/], + }, + }, + test: { + globals: true, + include: ['./**/*{.spec,.test}.{ts,tsx}'], + includeSource: ['./**/*.{ts,tsx}'], + reporters: ['dot'], + deps: {}, + passWithNoTests: true, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ed4fa07..defb9f3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -203,6 +203,25 @@ importers: specifier: 2.0.5 version: 2.0.5(@types/node@22.0.2)(happy-dom@9.1.9) + packages/sort: + dependencies: + '@alpinets/alpinets': + specifier: workspace:^ + version: link:../alpinets + sortablejs: + specifier: 1.15.2 + version: 1.15.2 + devDependencies: + '@types/sortablejs': + specifier: 1.15.8 + version: 1.15.8 + vite: + specifier: 5.3.5 + version: 5.3.5(@types/node@22.0.2) + vitest: + specifier: 2.0.5 + version: 2.0.5(@types/node@22.0.2)(happy-dom@9.1.9) + packages: '@ampproject/remapping@2.3.0': @@ -584,6 +603,9 @@ packages: '@types/node@22.0.2': resolution: {integrity: sha512-yPL6DyFwY5PiMVEwymNeqUTKsDczQBJ/5T7W/46RwLU/VH+AA8aT5TZkvBviLKLbbm0hlfftEkGrNzfRk/fofQ==} + '@types/sortablejs@1.15.8': + resolution: {integrity: sha512-b79830lW+RZfwaztgs1aVPgbasJ8e7AXtZYHTELNXZPsERt4ymJdjV4OccDbHQAvHrCcFpbF78jkm0R6h/pZVg==} + '@vitest/expect@2.0.5': resolution: {integrity: sha512-yHZtwuP7JZivj65Gxoi8upUN2OzHTi3zVfjwdpu2WrvCZPLwsJ2Ey5ILIPccoW23dd/zQBlJ4/dhi7DWNyXCpA==} @@ -1169,6 +1191,7 @@ packages: rimraf@3.0.2: resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true rollup@4.13.1: @@ -1225,6 +1248,9 @@ packages: resolution: {integrity: sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg==} engines: {node: '>=18'} + sortablejs@1.15.2: + resolution: {integrity: sha512-FJF5jgdfvoKn1MAKSdGs33bIqLi3LmsgVTliuX6iITj834F+JRQZN90Z93yql8h0K2t0RwDPBmxwlbZfDcxNZA==} + source-map-js@1.2.0: resolution: {integrity: sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==} engines: {node: '>=0.10.0'} @@ -1782,6 +1808,8 @@ snapshots: dependencies: undici-types: 6.11.1 + '@types/sortablejs@1.15.8': {} + '@vitest/expect@2.0.5': dependencies: '@vitest/spy': 2.0.5 @@ -2432,6 +2460,8 @@ snapshots: ansi-styles: 6.2.1 is-fullwidth-code-point: 5.0.0 + sortablejs@1.15.2: {} + source-map-js@1.2.0: {} source-map@0.6.1: {} diff --git a/size.json b/size.json index a54eb01..807f879 100644 --- a/size.json +++ b/size.json @@ -78,5 +78,15 @@ "pretty": "447 B", "raw": 447 } + }, + "sort": { + "minified": { + "pretty": "78.2 kB", + "raw": 78168 + }, + "brotli": { + "pretty": "25.3 kB", + "raw": 25287 + } } } \ No newline at end of file