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"