diff --git a/CHANGELOG.md b/CHANGELOG.md index 332a6428..382fdb0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +## [v1.0.0-alpha.9](https://github.com/studiometa/ui/compare/1.0.0-alpha.8..1.0.0-alpha.9) (2024-10-15) + +### Added + +- Add a [FigureShopify](https://ui.studiometa.dev/-/components/atoms/FigureShopify/) component ([#303](https://github.com/studiometa/ui/pull/303)) +- **Transition:** add support for grouped transitions ([#305](https://github.com/studiometa/ui/issues/305), [#306](https://github.com/studiometa/ui/pull/306), [be85501](https://github.com/studiometa/ui/commit/be85501)) + ## [v1.0.0-alpha.8](https://github.com/studiometa/ui/compare/1.0.0-alpha.7..1.0.0-alpha.8) (2024-09-25) ### Added diff --git a/composer.json b/composer.json index 86690b91..b279530d 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "studiometa/ui", - "version": "1.0.0-alpha.8", + "version": "1.0.0-alpha.9", "description": "A set of opiniated, unstyled and accessible components.", "license": "MIT", "require": { diff --git a/package-lock.json b/package-lock.json index 0bcafaff..cfbc32b6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@studiometa/ui-workspace", - "version": "1.0.0-alpha.8", + "version": "1.0.0-alpha.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@studiometa/ui-workspace", - "version": "1.0.0-alpha.8", + "version": "1.0.0-alpha.9", "hasInstallScript": true, "workspaces": [ "packages/*" @@ -2121,6 +2121,23 @@ "node": ">=8" } }, + "node_modules/@jest/fake-timers": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", + "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@sinonjs/fake-timers": "^10.0.2", + "@types/node": "*", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/@jest/schemas": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", @@ -2742,6 +2759,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", + "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.0" + } + }, "node_modules/@socket.io/component-emitter": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", @@ -3827,6 +3862,12 @@ "@types/node": "*" } }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "license": "MIT" + }, "node_modules/@types/unist": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.2.tgz", @@ -8360,17 +8401,19 @@ } }, "node_modules/happy-dom": { - "version": "14.12.3", - "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-14.12.3.tgz", - "integrity": "sha512-vsYlEs3E9gLwA1Hp+w3qzu+RUDFf4VTT8cyKqVICoZ2k7WM++Qyd2LwzyTi5bqMJFiIC/vNpTDYuxdreENRK/g==", + "version": "15.7.4", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-15.7.4.tgz", + "integrity": "sha512-r1vadDYGMtsHAAsqhDuk4IpPvr6N8MGKy5ntBo7tSdim+pWDxus2PNqOcOt8LuDZ4t3KJHE+gCuzupcx/GKnyQ==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "entities": "^4.5.0", "webidl-conversions": "^7.0.0", "whatwg-mimetype": "^3.0.0" }, "engines": { - "node": ">=16.0.0" + "node": ">=18.0.0" } }, "node_modules/has-ansi": { @@ -9345,6 +9388,49 @@ "@pkgjs/parseargs": "^0.11.0" } }, + "node_modules/jest-message-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", + "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^29.6.3", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-message-util/node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-mock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", + "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/jest-util": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", @@ -13770,6 +13856,32 @@ "renderkid": "^3.0.0" } }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/pretty-time": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/pretty-time/-/pretty-time-1.1.0.tgz", @@ -13868,6 +13980,12 @@ "node": ">= 0.8" } }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -15587,6 +15705,27 @@ "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", "license": "BSD-3-Clause" }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/stack-utils/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -16573,7 +16712,6 @@ "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", - "dev": true, "engines": { "node": ">=4" } @@ -18766,7 +18904,7 @@ }, "packages/docs": { "name": "@studiometa/ui-docs", - "version": "1.0.0-alpha.8", + "version": "1.0.0-alpha.9", "dependencies": { "@studiometa/js-toolkit": "^3.0.0-alpha.10" }, @@ -18786,7 +18924,7 @@ }, "packages/playground": { "name": "@studiometa/ui-playground", - "version": "1.0.0-alpha.8", + "version": "1.0.0-alpha.9", "dependencies": { "@studiometa/playground": "0.1.4", "copy-webpack-plugin": "^12.0.2" @@ -18794,18 +18932,46 @@ }, "packages/tests": { "name": "@studiometa/ui-tests", - "version": "1.0.0-alpha.8", + "version": "1.0.0-alpha.9", "license": "MIT", "dependencies": { + "@happy-dom/global-registrator": "^14.12.3", + "@jest/fake-timers": "^29.7.0", "@studiometa/ui": "file:../ui", "@vitest/coverage-v8": "2.0.5", - "happy-dom": "14.12.3", + "happy-dom": "^14.12.3", "vitest": "2.0.5" } }, + "packages/tests/node_modules/@happy-dom/global-registrator": { + "version": "14.12.3", + "resolved": "https://registry.npmjs.org/@happy-dom/global-registrator/-/global-registrator-14.12.3.tgz", + "integrity": "sha512-VL6mjnIhqD1l9zYBmdIx5Q3qNiNY0ZBByrDV2Z7hHDMsjhqwaVI2/uQZ7uyCsgRWvubSG1yZ74sEK2inDBBw/w==", + "license": "MIT", + "dependencies": { + "happy-dom": "^14.12.3" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "packages/tests/node_modules/happy-dom": { + "version": "14.12.3", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-14.12.3.tgz", + "integrity": "sha512-vsYlEs3E9gLwA1Hp+w3qzu+RUDFf4VTT8cyKqVICoZ2k7WM++Qyd2LwzyTi5bqMJFiIC/vNpTDYuxdreENRK/g==", + "license": "MIT", + "dependencies": { + "entities": "^4.5.0", + "webidl-conversions": "^7.0.0", + "whatwg-mimetype": "^3.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, "packages/ui": { "name": "@studiometa/ui", - "version": "1.0.0-alpha.8", + "version": "1.0.0-alpha.9", "license": "MIT", "dependencies": { "@studiometa/js-toolkit": "^3.0.0-alpha.10", diff --git a/package.json b/package.json index a8172708..bb5cdb90 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@studiometa/ui-workspace", - "version": "1.0.0-alpha.8", + "version": "1.0.0-alpha.9", "private": true, "workspaces": [ "packages/*" diff --git a/packages/docs/components/atoms/FigureShopify/examples.md b/packages/docs/components/atoms/FigureShopify/examples.md new file mode 100644 index 00000000..7061c44e --- /dev/null +++ b/packages/docs/components/atoms/FigureShopify/examples.md @@ -0,0 +1,31 @@ +--- +title: FigureShopify examples +--- + +# Examples + +## Simple + +This is a simple example using a 1 × 1 pixels transparent PNG as a placeholder. + + + +## Blurred placeholder + +This example uses a small sized image with a maximum width of 10 pixels with a blur backdrop-filter as a placeholder. + + + +## Advanced reveal + + diff --git a/packages/docs/components/atoms/FigureShopify/index.md b/packages/docs/components/atoms/FigureShopify/index.md new file mode 100644 index 00000000..c0d6fc6c --- /dev/null +++ b/packages/docs/components/atoms/FigureShopify/index.md @@ -0,0 +1,47 @@ +--- +badges: [JS] +--- + +# FigureShopify + +Use the `FigureShopify` component to display responsive images with [Shopify CDN `image_url` API](https://shopify.dev/docs/api/liquid/filters/image_url). + +## Table of content + +- [Examples](./examples.html) +- [JS API](./js-api.html) + +## Usage + +Register the component in your JavaScript app and use it in your templates. The component will transform the `data-src` URL to load an image at the dimension of the `` DOM element. + +```js {2,8} +import { Base, createApp } from '@studiometa/js-toolkit'; +import { FigureShopify } from '@studiometa/ui'; + +class App extends Base { + static config = { + name: 'Base', + components: { + Figure: FigureShopify, + } + }; +} + +export default createApp(App); +``` + +```liquid +
+
+ +
+
+``` + diff --git a/packages/docs/components/atoms/FigureShopify/js-api.md b/packages/docs/components/atoms/FigureShopify/js-api.md new file mode 100644 index 00000000..1dfa0331 --- /dev/null +++ b/packages/docs/components/atoms/FigureShopify/js-api.md @@ -0,0 +1,31 @@ +--- +title: FigureShopify JS API +outline: deep +--- + +# JS API + +The `FigureShopify` class extends the [`Figure` class](/components/atoms/Figure/js-api.html) and adds support for TwicPics API. + +## Options + +### `step` + +- Type: `number` +- Default: `50` + +The step used to round up image size calculation. Default to `50`, which means that a size of `320×380` will be rounded to `350×400`. + +### `crop` + +- Type: `string` +- Default: `'top' | 'left' | 'right' | 'bottom' | 'center'` + +If the image should be cropped (cover like), use the `crop` option with one the allowed value. + +### `disable` + +- Type: `boolean` +- Default: `false` + +Use this option to disable the features of this component. diff --git a/packages/docs/components/atoms/FigureShopify/stories/app.js b/packages/docs/components/atoms/FigureShopify/stories/app.js new file mode 100644 index 00000000..867fd724 --- /dev/null +++ b/packages/docs/components/atoms/FigureShopify/stories/app.js @@ -0,0 +1,13 @@ +import { Base, createApp } from '@studiometa/js-toolkit'; +import { FigureShopify } from '@studiometa/ui'; + +class App extends Base { + static config = { + name: 'App', + components: { + Figure: FigureShopify, + }, + }; +} + +export default createApp(App, document.body); diff --git a/packages/docs/components/atoms/FigureShopify/stories/app.liquid b/packages/docs/components/atoms/FigureShopify/stories/app.liquid new file mode 100644 index 00000000..39999cff --- /dev/null +++ b/packages/docs/components/atoms/FigureShopify/stories/app.liquid @@ -0,0 +1,12 @@ +
+
+ +
+
diff --git a/packages/docs/components/atoms/FigureShopify/stories/blurred.liquid b/packages/docs/components/atoms/FigureShopify/stories/blurred.liquid new file mode 100644 index 00000000..5212b75e --- /dev/null +++ b/packages/docs/components/atoms/FigureShopify/stories/blurred.liquid @@ -0,0 +1,22 @@ +
+
+ +
+ +
+
diff --git a/packages/docs/components/atoms/FigureShopify/stories/reveal.css b/packages/docs/components/atoms/FigureShopify/stories/reveal.css new file mode 100644 index 00000000..32450b0d --- /dev/null +++ b/packages/docs/components/atoms/FigureShopify/stories/reveal.css @@ -0,0 +1,12 @@ +html { + background-color: #f1c092; +} + +html.dark { + background-color: #a76844; + color: #eee; +} + +body { + padding: 1rem; +} diff --git a/packages/docs/components/atoms/FigureShopify/stories/reveal.js b/packages/docs/components/atoms/FigureShopify/stories/reveal.js new file mode 100644 index 00000000..0a047eb0 --- /dev/null +++ b/packages/docs/components/atoms/FigureShopify/stories/reveal.js @@ -0,0 +1,14 @@ +import { Base, createApp } from '@studiometa/js-toolkit'; +import { FigureShopify, Transition } from '@studiometa/ui'; + +class App extends Base { + static config = { + name: 'App', + components: { + Figure: FigureShopify, + Transition, + }, + }; +} + +export default createApp(App, document.body); diff --git a/packages/docs/components/atoms/FigureShopify/stories/reveal.liquid b/packages/docs/components/atoms/FigureShopify/stories/reveal.liquid new file mode 100644 index 00000000..f04a9298 --- /dev/null +++ b/packages/docs/components/atoms/FigureShopify/stories/reveal.liquid @@ -0,0 +1,26 @@ +
+ {% for i in 1..10 %} +
+
+
+ +
+
+ {% endfor %} +
diff --git a/packages/docs/components/primitives/Transition/examples.md b/packages/docs/components/primitives/Transition/examples.md index 77230c09..5c9bf54c 100644 --- a/packages/docs/components/primitives/Transition/examples.md +++ b/packages/docs/components/primitives/Transition/examples.md @@ -10,3 +10,12 @@ title: Transition examples :html="() => import('./stories/toggle/app.twig')" :script="() => import('./stories/toggle/app.js?raw')" /> + +## Group + +Use the [`group` option](/components/primitives/Transition/js-api.html#group) to keep multiple instances in sync. In the example below, the buttons only control the first component, the second one is synced with the `data-option-group` attribute. + + diff --git a/packages/docs/components/primitives/Transition/js-api.md b/packages/docs/components/primitives/Transition/js-api.md index b3708792..5a073d6e 100644 --- a/packages/docs/components/primitives/Transition/js-api.md +++ b/packages/docs/components/primitives/Transition/js-api.md @@ -120,6 +120,26 @@ Configure wether or not the `leaveTo` classes should be kept on the target eleme ``` +### `group` + +- Type: `String` +- Default: `''` + +Define a group to sync `enter` and `leave` transition between multiple instances. + +```html {2,7} +
+ ... +
+ +
+ ... +
+``` + + ## Properties ### `target` diff --git a/packages/docs/components/primitives/Transition/stories/group/app.js b/packages/docs/components/primitives/Transition/stories/group/app.js new file mode 100644 index 00000000..6d38d12a --- /dev/null +++ b/packages/docs/components/primitives/Transition/stories/group/app.js @@ -0,0 +1,14 @@ +import { Base, createApp } from '@studiometa/js-toolkit'; +import { Action, Transition } from '@studiometa/ui'; + +class App extends Base { + static config = { + name: 'App', + components: { + Action, + Transition, + }, + }; +} + +export default createApp(App); diff --git a/packages/docs/components/primitives/Transition/stories/group/app.twig b/packages/docs/components/primitives/Transition/stories/group/app.twig new file mode 100644 index 00000000..1a5828fa --- /dev/null +++ b/packages/docs/components/primitives/Transition/stories/group/app.twig @@ -0,0 +1,37 @@ +
+
+ {% include '@ui/atoms/Button/StyledButton.twig' with { + label: 'Enter', + attr: { + data_component: 'Action', + 'data-option-on:click': 'Transition(#one) -> target.enter()' + } + } %} + {% include '@ui/atoms/Button/StyledButton.twig' with { + label: 'Leave', + attr: { + data_component: 'Action', + 'data-option-on:click': 'Transition(#one) -> target.leave()' + } + } %} +
+
+
+
diff --git a/packages/docs/package.json b/packages/docs/package.json index 430f6385..16574ec0 100644 --- a/packages/docs/package.json +++ b/packages/docs/package.json @@ -1,6 +1,6 @@ { "name": "@studiometa/ui-docs", - "version": "1.0.0-alpha.8", + "version": "1.0.0-alpha.9", "private": true, "type": "module", "scripts": { diff --git a/packages/playground/package.json b/packages/playground/package.json index 999cd9d6..a4fbb97a 100644 --- a/packages/playground/package.json +++ b/packages/playground/package.json @@ -1,6 +1,6 @@ { "name": "@studiometa/ui-playground", - "version": "1.0.0-alpha.8", + "version": "1.0.0-alpha.9", "private": true, "type": "module", "scripts": { diff --git a/packages/tests/__utils__/faketimers.ts b/packages/tests/__utils__/faketimers.ts new file mode 100644 index 00000000..decbeef1 --- /dev/null +++ b/packages/tests/__utils__/faketimers.ts @@ -0,0 +1,36 @@ +import type { Config } from '@jest/types'; +import { ModernFakeTimers } from '@jest/fake-timers'; + +const fakeTimers = new ModernFakeTimers({ + global, + // @ts-ignore + config: {}, +}); + +let isUsingFakeTimers = false; + +export function isFakeTime() { + return isUsingFakeTimers; +} + +export function useFakeTimers(fakeTimersConfig?: Config.FakeTimersConfig) { + fakeTimers.useFakeTimers(fakeTimersConfig); + isUsingFakeTimers = true; +} + +export function useRealTimers() { + fakeTimers.useRealTimers(); + isUsingFakeTimers = false; +} + +export function advanceTimersByTime(msToRun: number) { + fakeTimers.advanceTimersByTime(msToRun); +} + +export async function advanceTimersByTimeAsync(msToRun: number) { + return fakeTimers.advanceTimersByTimeAsync(msToRun); +} + +export function runAllTimers() { + fakeTimers.runAllTimers(); +} diff --git a/packages/tests/__utils__/happydom.ts b/packages/tests/__utils__/happydom.ts index 5df616ca..a6a85f9a 100644 --- a/packages/tests/__utils__/happydom.ts +++ b/packages/tests/__utils__/happydom.ts @@ -1,3 +1,7 @@ +import { GlobalRegistrator } from '@happy-dom/global-registrator'; + +GlobalRegistrator.register(); + window.__DEV__ = true; let y = 0; diff --git a/packages/tests/__utils__/index.ts b/packages/tests/__utils__/index.ts index caf28e9e..44e8d6d7 100644 --- a/packages/tests/__utils__/index.ts +++ b/packages/tests/__utils__/index.ts @@ -1,5 +1,8 @@ export * from './components.js'; +export * from './faketimers.js'; export * from './h.js'; export * from './lifecycle.js'; +export * from './mockImageLoad.js'; export * from './mockIntersectionObserver.js'; +export * from './resizeWindow.js'; export * from './wait.js'; diff --git a/packages/tests/__utils__/mockImageLoad.ts b/packages/tests/__utils__/mockImageLoad.ts new file mode 100644 index 00000000..74a032b4 --- /dev/null +++ b/packages/tests/__utils__/mockImageLoad.ts @@ -0,0 +1,19 @@ +let tmp; + +export function mockImageLoad() { + tmp = global.Image; + global.Image = class extends tmp { + set src(src) { + this.setAttribute('src', src); + this.dispatchEvent(new Event('load')); + } + } +} + +export function unmockImageLoad() { + if (tmp) { + global.Image = tmp; + } + + tmp = null; +} diff --git a/packages/tests/__utils__/mockIntersectionObserver.ts b/packages/tests/__utils__/mockIntersectionObserver.ts index c53dae34..b94b0c1a 100644 --- a/packages/tests/__utils__/mockIntersectionObserver.ts +++ b/packages/tests/__utils__/mockIntersectionObserver.ts @@ -1,11 +1,17 @@ import { vi } from 'vitest'; +type Item = { + callback: IntersectionObserverCallback; + elements: Set; + created: number; +}; + /** * Thanks to the react-intersecton-observer package for this IntersectionObserver mock! * * @see https://github.com/thebuilder/react-intersection-observer/blob/master/src/test-utils.ts */ -const observers = new Map(); +const observers: Map = new Map(); export function intersectionObserverBeforeAllCallback() { /** @@ -14,20 +20,20 @@ export function intersectionObserverBeforeAllCallback() { * know which elements to trigger the event on. */ globalThis.IntersectionObserver = vi.fn( - (cb, options = {}) => { - const item = { + (cb: IntersectionObserverCallback, options: IntersectionObserverInit = {}) => { + const item: Item = { callback: cb, elements: new Set(), created: Date.now(), }; - const instance = { + const instance: IntersectionObserver = { thresholds: Array.isArray(options.threshold) ? options.threshold : [options.threshold ?? 0], root: options.root ?? null, rootMargin: options.rootMargin ?? '', - observe: vi.fn((element) => { + observe: vi.fn((element: Element) => { item.elements.add(element); }), - unobserve: vi.fn((element) => { + unobserve: vi.fn((element: Element) => { item.elements.delete(element); }), disconnect: vi.fn(() => { @@ -51,8 +57,8 @@ export function intersectionObserverAfterEachCallback() { function triggerIntersection( elements, - isIntersecting, - observer, + isIntersecting: boolean, + observer: IntersectionObserver, item, ) { const entries = []; @@ -74,7 +80,7 @@ function triggerIntersection( toJSON() {}, }, isIntersecting, - rootBounds: observer.root ? (observer.root).getBoundingClientRect() : null, + rootBounds: observer.root ? (observer.root as Element).getBoundingClientRect() : null, target: element, time: Date.now() - item.created, }); @@ -89,7 +95,7 @@ function triggerIntersection( * `IntersectionObserver` instance. You can use this to spy on the `observe` and * `unobserve` methods. */ -export function intersectionMockInstance(element) { +export function intersectionMockInstance(element: Element): IntersectionObserver { for (const [observer, item] of observers) { if (item.elements.has(element)) { return observer; @@ -102,7 +108,7 @@ export function intersectionMockInstance(element) { /** * Set the `isIntersecting` for the IntersectionObserver of a specific element. */ -export async function mockIsIntersecting(element, isIntersecting) { +export function mockIsIntersecting(element: Element, isIntersecting: boolean) { const observer = intersectionMockInstance(element); if (!observer) { throw new Error( @@ -111,9 +117,6 @@ export async function mockIsIntersecting(element, isIntersecting) { } const item = observers.get(observer); if (item) { - vi.useFakeTimers(); triggerIntersection([element], isIntersecting, observer, item); - await vi.advanceTimersByTimeAsync(10); - vi.useRealTimers(); } } diff --git a/packages/tests/__utils__/resizeWindow.ts b/packages/tests/__utils__/resizeWindow.ts new file mode 100644 index 00000000..290161f7 --- /dev/null +++ b/packages/tests/__utils__/resizeWindow.ts @@ -0,0 +1,23 @@ +import { + useFakeTimers, + useRealTimers, + advanceTimersByTimeAsync, + isFakeTime, +} from './faketimers.js'; + +export async function resizeWindow({ + width = window.innerWidth, + height = window.innerHeight, +} = {}) { + const hasFakeTimer = isFakeTime(); + if (!hasFakeTimer) { + useFakeTimers(); + } + window.innerWidth = width; + window.innerHeight = height; + window.dispatchEvent(new Event('resize')); + await advanceTimersByTimeAsync(400); + if (!hasFakeTimer) { + useRealTimers(); + } +} diff --git a/packages/tests/atoms/Figure.spec.ts b/packages/tests/atoms/Figure.spec.ts new file mode 100644 index 00000000..c8b65b1f --- /dev/null +++ b/packages/tests/atoms/Figure.spec.ts @@ -0,0 +1,53 @@ +import { it, describe, expect, vi, beforeAll, beforeEach, afterEach } from 'vitest'; +import { getInstanceFromElement } from '@studiometa/js-toolkit'; +import { Figure } from '@studiometa/ui'; +import { + wait, + hConnected as h, + mockIsIntersecting, + intersectionObserverBeforeAllCallback, + intersectionObserverAfterEachCallback, + mockImageLoad, + unmockImageLoad, +} from '#test-utils'; + +beforeAll(() => { + intersectionObserverBeforeAllCallback(); +}); + +beforeEach(() => { + mockImageLoad(); +}); + +afterEach(() => { + intersectionObserverAfterEachCallback(); + unmockImageLoad(); +}); + +describe('The Figure component', () => { + it('should load the original image lazily and terminate the instance', async () => { + const src = 'http://localhost/img.jpg'; + const img = h('img', { + dataRef: 'img', + src: 'data:image/svg+xml,', + dataSrc: src, + }); + const figure = h('figure', { dataOptionLazy: '' }, [img]); + + const instance = new Figure(figure); + expect(img.src).not.toBe(src); + mockIsIntersecting(figure, true); + await wait(10); + expect(img.src).toBe(src); + expect(getInstanceFromElement(figure, Figure)).toBe('terminated'); + }); + + it('should warn if the `img` ref is misconfigured', async () => { + const div = h('div'); + const instance = new Figure(div); + const warnSpy = vi.spyOn(instance, '$warn'); + mockIsIntersecting(div, true); + await wait(10); + expect(warnSpy).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/tests/atoms/FigureShopify.spec.ts b/packages/tests/atoms/FigureShopify.spec.ts new file mode 100644 index 00000000..fb741680 --- /dev/null +++ b/packages/tests/atoms/FigureShopify.spec.ts @@ -0,0 +1,76 @@ +import { it, describe, vi, expect, beforeAll, afterEach } from 'vitest'; +import { Base } from '@studiometa/js-toolkit'; +import { FigureShopify } from '@studiometa/ui'; +import { + wait, + hConnected as h, + mockIsIntersecting, + mount, + intersectionObserverBeforeAllCallback, + intersectionObserverAfterEachCallback, +} from '#test-utils'; + +beforeAll(() => { + intersectionObserverBeforeAllCallback(); +}); + +afterEach(() => { + intersectionObserverAfterEachCallback(); +}); + +async function getContext({ figureAttributes = {} } = {}) { + const img = h('img', { + dataRef: 'img', + src: 'data:image/svg+xml,', + dataSrc: 'https://localhost/image.jpg', + }); + const figure = h('figure', { dataOptionLazy: '', ...figureAttributes }, [img]); + + const widthSpy = vi.spyOn(img, 'offsetWidth', 'get'); + widthSpy.mockImplementation(() => 100); + const heightSpy = vi.spyOn(img, 'offsetHeight', 'get'); + heightSpy.mockImplementation(() => 100); + + const instance = new FigureShopify(figure); + mockIsIntersecting(figure, true); + await wait(100); + + return { + img, + figure, + instance, + setSize({ width, height }: { width?: number; height?: number } = {}) { + if (width) { + widthSpy.mockImplementation(() => width); + } + if (height) { + heightSpy.mockImplementation(() => height); + } + }, + }; +} + +describe('The FigureShopify component', () => { + it('should override the original image', async () => { + const { instance, setSize } = await getContext(); + expect(instance.original).toBe('https://localhost/image.jpg?width=100&height=100'); + setSize({ width: 200, height: 200 }); + expect(instance.original).toBe('https://localhost/image.jpg?width=200&height=200'); + }); + + it('should not override the original image when disabled', async () => { + const { instance, setSize } = await getContext(); + instance.$options.disable = true; + expect(instance.original).toBe('https://localhost/image.jpg'); + }); + + it('should add a crop parameter', async () => { + const { instance, setSize } = await getContext({ + figureAttributes: { dataOptionCrop: 'center' }, + }); + + expect(instance.original).toBe('https://localhost/image.jpg?width=100&height=100&crop=center'); + setSize({ width: 200, height: 200 }); + expect(instance.original).toBe('https://localhost/image.jpg?width=200&height=200&crop=center'); + }); +}); diff --git a/packages/tests/atoms/FigureTwicpics.spec.ts b/packages/tests/atoms/FigureTwicpics.spec.ts new file mode 100644 index 00000000..3f7aeac4 --- /dev/null +++ b/packages/tests/atoms/FigureTwicpics.spec.ts @@ -0,0 +1,113 @@ +import { it, describe, vi, expect, beforeAll, afterEach, beforeEach } from 'vitest'; +import { Base } from '@studiometa/js-toolkit'; +import { FigureTwicpics } from '@studiometa/ui'; +import { + wait, + hConnected as h, + mockIsIntersecting, + mount, + intersectionObserverBeforeAllCallback, + intersectionObserverAfterEachCallback, + unmockImageLoad, + mockImageLoad, +} from '#test-utils'; +import { resizeWindow } from '../__utils__/resizeWindow'; + +beforeAll(() => { + intersectionObserverBeforeAllCallback(); +}); + +beforeEach(() => { + mockImageLoad(); +}); + +afterEach(() => { + intersectionObserverAfterEachCallback(); + unmockImageLoad(); +}); + +async function getContext({ figureAttributes = {} } = {}) { + const img = h('img', { + dataRef: 'img', + src: 'data:image/svg+xml,', + dataSrc: 'https://localhost/image.jpg', + }); + const figure = h('figure', { dataOptionLazy: '', ...figureAttributes }, [img]); + + const widthSpy = vi.spyOn(img, 'offsetWidth', 'get'); + widthSpy.mockImplementation(() => 100); + const heightSpy = vi.spyOn(img, 'offsetHeight', 'get'); + heightSpy.mockImplementation(() => 100); + + const instance = new FigureTwicpics(figure); + mockIsIntersecting(figure, true); + await wait(100); + + return { + img, + figure, + instance, + setSize({ width, height }: { width?: number; height?: number } = {}) { + if (width) { + widthSpy.mockImplementation(() => width); + } + if (height) { + heightSpy.mockImplementation(() => height); + } + }, + }; +} + +describe('The FigureTwicpics component', () => { + it('should override the original image', async () => { + const { instance, setSize } = await getContext(); + expect(instance.original).toBe('https://localhost/image.jpg?twic=v1/cover=100x100'); + setSize({ width: 200, height: 200 }); + expect(instance.original).toBe('https://localhost/image.jpg?twic=v1/cover=200x200'); + }); + + it('should update the image source on resize', async () => { + const { instance, setSize, img } = await getContext(); + expect(instance.original).toBe('https://localhost/image.jpg?twic=v1/cover=100x100'); + expect(img.src).toBe('https://localhost/image.jpg?twic=v1/cover=100x100'); + setSize({ width: 200, height: 200 }); + await resizeWindow(); + expect(instance.original).toBe('https://localhost/image.jpg?twic=v1/cover=200x200'); + expect(img.src).toBe('https://localhost/image.jpg?twic=v1/cover=200x200'); + }); + + it('should set the domain and path', async () => { + const { instance, setSize } = await getContext({ + figureAttributes: { + dataOptionDomain: 'twic.pics', + dataOptionPath: 'path', + } + }); + + expect(instance.$options.domain).toBe('twic.pics'); + expect(instance.domain).toBe('twic.pics'); + expect(instance.$options.path).toBe('path'); + expect(instance.path).toBe('path'); + expect(instance.original).toBe('https://twic.pics/path/image.jpg?twic=v1/cover=100x100'); + + instance.$el.removeAttribute('data-option-domain'); + + expect(instance.$options.domain).toBe(''); + expect(instance.domain).toBe('localhost'); + expect(instance.original).toBe('https://localhost/path/image.jpg?twic=v1/cover=100x100'); + }); + + it('should take the device pixel ratio into account', async () => { + const { instance } = await getContext(); + + window.devicePixelRatio = 2; + + expect(instance.devicePixelRatio).toBe(2); + expect(instance.original).toBe('https://localhost/image.jpg?twic=v1/cover=200x200'); + + instance.$el.setAttribute('data-option-no-dpr', ''); + + expect(instance.devicePixelRatio).toBe(1); + expect(instance.original).toBe('https://localhost/image.jpg?twic=v1/cover=100x100'); + }); +}); diff --git a/packages/tests/index.spec.ts b/packages/tests/index.spec.ts index bd48ce46..6c48b176 100644 --- a/packages/tests/index.spec.ts +++ b/packages/tests/index.spec.ts @@ -22,6 +22,7 @@ test('components exports', () => { "DataModel", "Draggable", "Figure", + "FigureShopify", "FigureTwicpics", "FigureVideo", "FigureVideoTwicpics", @@ -60,7 +61,6 @@ test('components exports', () => { "Target", "Transition", "animationScrollWithEase", - "loadImage", "withDeprecation", "withTransition", ] diff --git a/packages/tests/molecules/AnchorNav.spec.ts b/packages/tests/molecules/AnchorNav.spec.ts index 576e2f59..bfd10a13 100644 --- a/packages/tests/molecules/AnchorNav.spec.ts +++ b/packages/tests/molecules/AnchorNav.spec.ts @@ -5,6 +5,7 @@ import { intersectionObserverBeforeAllCallback, intersectionObserverAfterEachCallback, mockIsIntersecting, + mount, } from '#test-utils'; beforeAll(() => { @@ -65,10 +66,8 @@ async function getContext() { const targetOne = div.querySelector('#one'); const anchorNavTest = new AnchorNavTest(div); - vi.useFakeTimers(); - anchorNavTest.$mount(); - await vi.advanceTimersByTimeAsync(100); - vi.useRealTimers(); + + await mount(anchorNavTest); return { mountedFn, @@ -87,13 +86,14 @@ describe('The `AnchorNav` component', () => { expect(mountedFn).toHaveBeenCalledTimes(0); expect(destroyedFn).toHaveBeenCalledTimes(0); - await mockIsIntersecting(targetOne, true); + mockIsIntersecting(targetOne, true); + await wait(10); expect(mountedFn).toHaveBeenCalledTimes(1); expect(destroyedFn).toHaveBeenCalledTimes(0); - await mockIsIntersecting(targetOne, false); - await wait(1); + mockIsIntersecting(targetOne, false); + await wait(10); expect(destroyedFn).toHaveBeenCalledTimes(1); }); @@ -103,13 +103,14 @@ describe('The `AnchorNav` component', () => { expect(enterFn).toHaveBeenCalledTimes(0); expect(leaveFn).toHaveBeenCalledTimes(0); - await mockIsIntersecting(targetOne, true); + mockIsIntersecting(targetOne, true); + await wait(10); expect(enterFn).toHaveBeenCalledTimes(1); expect(leaveFn).toHaveBeenCalledTimes(0); - await mockIsIntersecting(targetOne, false); - await wait(1); + mockIsIntersecting(targetOne, false); + await wait(10); expect(leaveFn).toHaveBeenCalledTimes(1); }); diff --git a/packages/tests/package.json b/packages/tests/package.json index 41a3b071..d17bada9 100644 --- a/packages/tests/package.json +++ b/packages/tests/package.json @@ -1,6 +1,6 @@ { "name": "@studiometa/ui-tests", - "version": "1.0.0-alpha.8", + "version": "1.0.0-alpha.9", "private": true, "type": "module", "scripts": { @@ -9,9 +9,11 @@ "author": "Studio Meta (https://www.studiometa.fr/)", "license": "MIT", "dependencies": { + "@happy-dom/global-registrator": "^14.12.3", + "@jest/fake-timers": "^29.7.0", "@studiometa/ui": "file:../ui", "@vitest/coverage-v8": "2.0.5", - "happy-dom": "14.12.3", + "happy-dom": "^14.12.3", "vitest": "2.0.5" }, "imports": { diff --git a/packages/tests/primitives/Transition.spec.ts b/packages/tests/primitives/Transition.spec.ts new file mode 100644 index 00000000..59f5022a --- /dev/null +++ b/packages/tests/primitives/Transition.spec.ts @@ -0,0 +1,26 @@ +import { describe, it, expect, vi } from 'vitest'; +import { Transition } from '@studiometa/ui'; +import { mount, h } from '#test-utils'; + +describe('The Transition component', () => { + it('should default to its root element as target', async () => { + const div = h('div'); + const transition = new Transition(div); + expect(transition.target).toBe(div); + }); + + it('should dispatch enter and leave method to grouped elements', async () => { + const opts = { dataOptionGroup: 'group' }; + const transitionA = new Transition(h('div', opts)); + const transitionB = new Transition(h('div', opts)); + const enterMock = vi.spyOn(transitionA, 'enter'); + const leaveMock = vi.spyOn(transitionA, 'enter'); + + await mount(transitionA, transitionB); + expect(transitionA.$options.group).toBe(transitionB.$options.group); + await transitionB.enter(); + expect(enterMock).toHaveBeenCalledOnce(); + await transitionB.leave(); + expect(leaveMock).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/ui/atoms/Figure/AbstractFigure.ts b/packages/ui/atoms/Figure/AbstractFigure.ts new file mode 100644 index 00000000..afdf42f0 --- /dev/null +++ b/packages/ui/atoms/Figure/AbstractFigure.ts @@ -0,0 +1,85 @@ +import { withMountWhenInView } from '@studiometa/js-toolkit'; +import type { BaseConfig, BaseProps } from '@studiometa/js-toolkit'; +import { Transition } from '../../primitives/index.js'; +import { loadImage } from './utils.js'; + +export interface AbstractFigureProps extends BaseProps { + $refs: { + img: HTMLImageElement; + }; + $options: { + lazy: boolean; + }; +} + +/** + * Figure class. + */ +export class AbstractFigure< + T extends BaseProps = BaseProps, +> extends withMountWhenInView(Transition, { + threshold: [0, 1], +}) { + /** + * Config. + */ + static config: BaseConfig = { + ...Transition.config, + name: 'AbstractFigure', + emits: ['load'], + refs: ['img'], + options: { + ...Transition.config.options, + lazy: Boolean, + }, + }; + + /** + * Get the transition target. + */ + get target() { + return this.$refs.img; + } + + /** + * Get the image source. + */ + get src() { + return this.$refs.img.src; + } + + /** + * Set the image source. + */ + set src(value: string) { + this.$refs.img.src = value; + } + + /** + * Get the original source. + */ + get original() { + return this.$refs.img.dataset.src; + } + + /** + * Load on mount. + */ + async mounted() { + const { img } = this.$refs; + + if (!img || !(img instanceof HTMLImageElement)) { + this.$warn('The `img` refs is missing or not an `` element.'); + return; + } + + const src = this.original; + + if (this.$options.lazy && src && src !== this.src) { + await loadImage(src); + this.src = src; + this.enter(); + this.$emit('load'); + } + } +} diff --git a/packages/ui/atoms/Figure/AbstractFigureDynamic.ts b/packages/ui/atoms/Figure/AbstractFigureDynamic.ts new file mode 100644 index 00000000..4ad54664 --- /dev/null +++ b/packages/ui/atoms/Figure/AbstractFigureDynamic.ts @@ -0,0 +1,66 @@ +import type { BaseConfig, BaseProps } from '@studiometa/js-toolkit'; +import { + withLeadingSlash, + withoutLeadingSlash, + withoutTrailingSlash, +} from '@studiometa/js-toolkit/utils'; +import { AbstractFigure } from './AbstractFigure.js'; +import { loadImage } from './utils.js'; + +export interface AbstractFigureDynamicProps extends BaseProps { + $options: { + disable: boolean; + step: number; + }; +} + +/** + * AbstractFigureDynamic class. + */ +export class AbstractFigureDynamic extends AbstractFigure< + T & AbstractFigureDynamicProps +> { + /** + * Config. + */ + static config: BaseConfig = { + ...AbstractFigure.config, + name: 'AbstractFigureDynamic', + options: { + ...AbstractFigure.config.options, + disable: Boolean, + step: { + type: Number, + default: 50, + }, + lazy: { + type: Boolean, + default: true, + }, + }, + }; + + /** + * Get the formatted source or the original based on the `disable` option. + */ + get original() { + return this.$options.disable ? super.original : this.formatSrc(super.original); + } + + /** + * Format the source with dynamic parameters. + */ + /* v8 ignore next 3 */ + formatSrc(src: string): string { + return src; + } + + /** + * Reassign the source from the original on resize. + */ + async resized() { + const { original } = this; + await loadImage(original); + this.src = original; + } +} diff --git a/packages/ui/atoms/Figure/Figure.ts b/packages/ui/atoms/Figure/Figure.ts index d9a44a81..f59f9102 100644 --- a/packages/ui/atoms/Figure/Figure.ts +++ b/packages/ui/atoms/Figure/Figure.ts @@ -1,102 +1,22 @@ import { withMountWhenInView } from '@studiometa/js-toolkit'; import type { BaseConfig, BaseProps } from '@studiometa/js-toolkit'; -import { Transition } from '../../primitives/index.js'; +import { AbstractFigure } from './AbstractFigure.js'; +import type { AbstractFigureProps } from './AbstractFigure.js'; -export interface FigureProps extends BaseProps { - $refs: { - img: HTMLImageElement; - }; - $options: { - lazy: boolean; - }; -} - -/** - * Load the given image. - */ -export function loadImage(src: string): Promise { - return new Promise((resolve) => { - const img = new Image(); - img.addEventListener('load', () => resolve(img)); - img.src = src; - }); -} +export interface FigureProps extends AbstractFigureProps {} /** * Figure class. */ -export class Figure extends withMountWhenInView( - Transition, - { - threshold: [0, 1], - }, -) { +export class Figure extends AbstractFigure { /** * Config. */ static config: BaseConfig = { - ...Transition.config, + ...AbstractFigure.config, name: 'Figure', - emits: ['load'], - refs: ['img'], - options: { - ...Transition.config.options, - lazy: Boolean, - }, }; - /** - * Get the transition target. - */ - get target() { - return this.$refs.img; - } - - /** - * Get the image source. - */ - get src() { - return this.$refs.img.src; - } - - /** - * Set the image source. - */ - set src(value: string) { - this.$refs.img.src = value; - } - - /** - * Get the original source. - */ - get original() { - return this.$refs.img.dataset.src; - } - - /** - * Load on mount. - */ - async mounted() { - const { img } = this.$refs; - - if (!img) { - throw new Error('[Figure] The `img` ref is required.'); - } - - if (!(img instanceof HTMLImageElement)) { - throw new Error('[Figure] The `img` ref must be an `` element.'); - } - - const src = this.original; - - if (this.$options.lazy && src && src !== this.src) { - await loadImage(src); - this.src = src; - this.enter(); - this.$emit('load'); - } - } - /** * Terminate the component on load. */ diff --git a/packages/ui/atoms/Figure/FigureShopify.ts b/packages/ui/atoms/Figure/FigureShopify.ts new file mode 100644 index 00000000..6ead3702 --- /dev/null +++ b/packages/ui/atoms/Figure/FigureShopify.ts @@ -0,0 +1,54 @@ +import type { BaseConfig, BaseProps } from '@studiometa/js-toolkit'; +import { AbstractFigureDynamic } from './AbstractFigureDynamic.js'; +import { normalizeSize } from './utils.js'; + +export interface FigureShopifyProps extends BaseProps { + $options: { + crop?: 'top' | 'left' | 'right' | 'bottom' | 'center'; + }; +} + +/** + * Figure class. + * + * Manager lazyloading image sources. + */ +export class FigureShopify extends AbstractFigureDynamic< + T & FigureShopifyProps +> { + /** + * Config. + */ + static config: BaseConfig = { + ...AbstractFigureDynamic.config, + name: 'FigureShopify', + options: { + ...AbstractFigureDynamic.config.options, + crop: { + type: String, + default: null, + }, + }, + }; + + /** + * Format the source for Shopify CDN API. + * @see https://shopify.dev/docs/api/liquid/filters/image_url + */ + formatSrc(src: string): string { + const { crop, step } = this.$options; + + const url = new URL(src, 'https://localhost'); + const width = normalizeSize(this.$refs.img.offsetWidth, step) * window.devicePixelRatio; + const height = normalizeSize(this.$refs.img.offsetHeight, step) * window.devicePixelRatio; + + url.searchParams.set('width', String(width)); + url.searchParams.set('height', String(height)); + + if (crop) { + url.searchParams.set('crop', this.$options.crop); + } + + return url.toString(); + } +} diff --git a/packages/ui/atoms/Figure/FigureTwicpics.ts b/packages/ui/atoms/Figure/FigureTwicpics.ts index 20a9381b..fc4017a5 100644 --- a/packages/ui/atoms/Figure/FigureTwicpics.ts +++ b/packages/ui/atoms/Figure/FigureTwicpics.ts @@ -4,49 +4,38 @@ import { withoutLeadingSlash, withoutTrailingSlash, } from '@studiometa/js-toolkit/utils'; -import { Figure, loadImage } from './Figure.js'; +import { AbstractFigureDynamic } from './AbstractFigureDynamic.js'; +import { normalizeSize } from './utils.js'; export interface FigureTwicpicsProps extends BaseProps { $options: { transform: string; domain: string; path: string; - step: number; mode: string; dpr: boolean; }; } -/** - * Normalize the given size to the step option. - */ -// eslint-disable-next-line no-use-before-define -function normalizeSize(that: FigureTwicpics, prop: string): number { - const { step } = that.$options; - return Math.ceil(that.$refs.img[prop] / step) * step; -} - /** * Determine if the user agent is a bot or not. */ const isBot = /bot|crawl|slurp|spider/i.test(navigator.userAgent); /** - * Figure class. - * - * Manager lazyloading image sources. + * FigureTwicpics class. */ -export class FigureTwicpics extends Figure< +export class FigureTwicpics extends AbstractFigureDynamic< T & FigureTwicpicsProps > { /** * Config. */ static config: BaseConfig = { - ...Figure.config, + ...AbstractFigureDynamic.config, name: 'FigureTwicpics', options: { - ...Figure.config.options, + ...AbstractFigureDynamic.config.options, transform: String, domain: String, path: String, @@ -77,16 +66,7 @@ export class FigureTwicpics extends Figure< * Get the Twicpics domain. */ get domain(): string { - const url = new URL(this.$refs.img.dataset.src); - return url.host; - } - - /** - * Get formatted original source. - * If `disable` option is `true` returns the original src. - */ - get original() { - return this.$options.disable ? super.original : this.formatSrc(super.original); + return this.$options.domain || new URL(this.$refs.img.dataset.src).host; } /** @@ -106,6 +86,8 @@ export class FigureTwicpics extends Figure< * Format the source for Twicpics. */ formatSrc(src: string): string { + const { transform, mode, step } = this.$options; + const url = new URL(src, 'https://localhost'); url.host = this.domain; url.port = ''; @@ -114,33 +96,16 @@ export class FigureTwicpics extends Figure< url.pathname = `/${this.path}${url.pathname}`; } - const width = normalizeSize(this, 'offsetWidth') * this.devicePixelRatio; - const height = normalizeSize(this, 'offsetHeight') * this.devicePixelRatio; + const width = normalizeSize(this.$refs.img.offsetWidth, step) * this.devicePixelRatio; + const height = normalizeSize(this.$refs.img.offsetHeight, step) * this.devicePixelRatio; url.searchParams.set( 'twic', - ['v1', this.$options.transform, `${this.$options.mode}=${width}x${height}`] - .filter(Boolean) - .join('/'), + ['v1', transform, `${mode}=${width}x${height}`].filter(Boolean).join('/'), ); url.search = decodeURIComponent(url.search); return url.toString(); } - - /** - * Reassign the source from the original on resize. - */ - async resized() { - const { src } = await loadImage(this.original); - this.src = src; - } - - /** - * Do not terminate on image load as we need to set the src on resize. - */ - onLoad() { - // Do not terminate on image load as we need. - } } diff --git a/packages/ui/atoms/Figure/index.ts b/packages/ui/atoms/Figure/index.ts index 0c674172..44105ead 100644 --- a/packages/ui/atoms/Figure/index.ts +++ b/packages/ui/atoms/Figure/index.ts @@ -1,2 +1,3 @@ export * from './Figure.js'; export * from './FigureTwicpics.js'; +export * from './FigureShopify.js'; diff --git a/packages/ui/atoms/Figure/utils.ts b/packages/ui/atoms/Figure/utils.ts new file mode 100644 index 00000000..a727de10 --- /dev/null +++ b/packages/ui/atoms/Figure/utils.ts @@ -0,0 +1,17 @@ +/** + * Load the given image. + */ +export function loadImage(src: string): Promise { + return new Promise((resolve) => { + const img = new Image(); + img.addEventListener('load', () => resolve(img)); + img.src = src; + }); +} + +/** + * Normalize a size to the given step. + */ +export function normalizeSize(size:number, step:number): number { + return Math.ceil(size / step) * step; +} diff --git a/packages/ui/atoms/FigureVideo/FigureVideo.ts b/packages/ui/atoms/FigureVideo/FigureVideo.ts index a275e7e6..f207ce85 100644 --- a/packages/ui/atoms/FigureVideo/FigureVideo.ts +++ b/packages/ui/atoms/FigureVideo/FigureVideo.ts @@ -1,7 +1,7 @@ import { withMountWhenInView } from '@studiometa/js-toolkit'; import type { BaseConfig, BaseProps } from '@studiometa/js-toolkit'; import { Transition } from '../../primitives/index.js'; -import { loadImage } from '../index.js'; +import { loadImage } from '../Figure/utils.js'; export interface FigureVideoProps extends BaseProps { $refs: { diff --git a/packages/ui/atoms/FigureVideo/FigureVideoTwicpics.ts b/packages/ui/atoms/FigureVideo/FigureVideoTwicpics.ts index ab5f418a..ca87d84c 100644 --- a/packages/ui/atoms/FigureVideo/FigureVideoTwicpics.ts +++ b/packages/ui/atoms/FigureVideo/FigureVideoTwicpics.ts @@ -4,7 +4,7 @@ import { withoutLeadingSlash, withoutTrailingSlash, } from '@studiometa/js-toolkit/utils'; -import { loadImage } from '../index.js'; +import { loadImage } from '../Figure/utils.js'; import { FigureVideo } from './FigureVideo.js'; export interface FigureVideoTwicpicsProps extends BaseProps { diff --git a/packages/ui/decorators/withTransition.ts b/packages/ui/decorators/withTransition.ts index 1262c368..d42d4674 100644 --- a/packages/ui/decorators/withTransition.ts +++ b/packages/ui/decorators/withTransition.ts @@ -1,3 +1,4 @@ +import { getInstances } from '@studiometa/js-toolkit'; import { transition } from '@studiometa/js-toolkit/utils'; import type { Base, @@ -17,6 +18,7 @@ export interface TransitionProps extends BaseProps { leaveActive: string; leaveTo: string; leaveKeep: boolean; + group: string; }; } @@ -59,6 +61,7 @@ export function withTransition( leaveActive: String, leaveTo: String, leaveKeep: Boolean, + group: String, }, }; @@ -72,37 +75,64 @@ export function withTransition( /** * Trigger the enter transition. */ - async enter(target?: HTMLElement | HTMLElement[]): Promise { + async enter(target?: HTMLElement | HTMLElement[], { dispatch = true } = {}): Promise { const { enterFrom, enterActive, enterTo, enterKeep, leaveTo } = this.$options; - await transition( - target ?? this.target, - { - // eslint-disable-next-line prefer-template - from: (leaveTo + ' ' + enterFrom).trim(), - active: enterActive as string, - to: enterTo as string, - }, - enterKeep ? 'keep' : undefined, - ); + await Promise.all([ + transition( + target ?? this.target, + { + // eslint-disable-next-line prefer-template + from: (leaveTo + ' ' + enterFrom).trim(), + active: enterActive as string, + to: enterTo as string, + }, + enterKeep ? 'keep' : undefined, + ), + dispatch && this.dispatch('enter'), + ]); } /** * Trigger the leave transition. */ - async leave(target?: HTMLElement | HTMLElement[]): Promise { + async leave(target?: HTMLElement | HTMLElement[], { dispatch = true } = {}): Promise { const { leaveFrom, leaveActive, leaveTo, leaveKeep, enterTo } = this.$options; - await transition( - target ?? this.target, - { - // eslint-disable-next-line prefer-template - from: (enterTo + ' ' + leaveFrom).trim(), - active: leaveActive as string, - to: leaveTo as string, - }, - leaveKeep ? 'keep' : undefined, - ); + await Promise.all([ + transition( + target ?? this.target, + { + // eslint-disable-next-line prefer-template + from: (enterTo + ' ' + leaveFrom).trim(), + active: leaveActive as string, + to: leaveTo as string, + }, + leaveKeep ? 'keep' : undefined, + ), + dispatch && this.dispatch('leave'), + ]); + } + + /** + * Dispatch the callback to related instances. + */ + async dispatch(method: 'enter' | 'leave') { + const { group } = this.$options; + + if (!group) { + return; + } + + const promises = []; + + for (const instance of getInstances(Transition)) { + if (instance !== this && instance.$options.group === group) { + promises.push(instance[method](undefined, { dispatch: false })); + } + } + + await Promise.all(promises); } } diff --git a/packages/ui/package.json b/packages/ui/package.json index d658c46f..2375933f 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@studiometa/ui", - "version": "1.0.0-alpha.8", + "version": "1.0.0-alpha.9", "description": "A set of opiniated, unstyled and accessible components", "publishConfig": { "access": "public"