From 9c73393916d8a64a6a42eda75ed9aa63f5b588f0 Mon Sep 17 00:00:00 2001 From: Shane Osbourne Date: Fri, 4 Oct 2024 20:50:05 -0400 Subject: [PATCH] favorites drag and drop --- package-lock.json | 97 +++++++ packages/special-pages/package.json | 42 +++ .../messages/new-tab/examples/stats.js | 3 +- .../new-tab/favorites_getConfig.request.json | 3 + .../new-tab/favorites_getConfig.response.json | 8 + .../new-tab/favorites_getData.request.json | 3 + .../new-tab/favorites_getData.response.json | 8 + .../new-tab/favorites_move.notify.json | 19 ++ .../favorites_onConfigUpdate.subscribe.json | 8 + .../favorites_onDataUpdate.subscribe.json | 8 + .../favorites_openContextMenu.notify.json | 14 + .../new-tab/favorites_setConfig.notify.json | 8 + .../new-tab/types/favorites-config.json | 10 + .../new-tab/types/favorites-data.json | 30 ++ special-pages/package.json | 6 +- .../new-tab/app/components/Components.jsx | 15 +- .../app/components/Components.module.css | 6 + .../pages/new-tab/app/components/Examples.jsx | 62 +++++ special-pages/pages/new-tab/app/docs.js | 1 + .../pages/new-tab/app/favorites/Favorites.js | 261 +++++++++++++++++- .../app/favorites/Favorites.module.css | 201 ++++++++++++++ .../app/favorites/FavoritesProvider.js | 104 +++++++ .../new-tab/app/favorites/FavouritesGrid.js | 93 +++++++ .../pages/new-tab/app/favorites/Tile.js | 139 ++++++++++ .../app/favorites/favorites.service.js | 163 +++++++++++ .../favorites/mocks/MockFavoritesProvider.js | 76 +++++ .../app/favorites/mocks/favorites.data.js | 52 ++++ special-pages/pages/new-tab/app/service.js | 2 +- .../pages/new-tab/src/js/mock-transport.js | 91 +++++- .../pages/new-tab/src/locales/en/newtab.json | 8 + special-pages/playwright.config.js | 1 + special-pages/tests/new-tab-widgets.spec.js | 16 ++ special-pages/types/new-tab.ts | 125 +++++++-- 33 files changed, 1652 insertions(+), 31 deletions(-) create mode 100644 packages/special-pages/package.json create mode 100644 special-pages/messages/new-tab/favorites_getConfig.request.json create mode 100644 special-pages/messages/new-tab/favorites_getConfig.response.json create mode 100644 special-pages/messages/new-tab/favorites_getData.request.json create mode 100644 special-pages/messages/new-tab/favorites_getData.response.json create mode 100644 special-pages/messages/new-tab/favorites_move.notify.json create mode 100644 special-pages/messages/new-tab/favorites_onConfigUpdate.subscribe.json create mode 100644 special-pages/messages/new-tab/favorites_onDataUpdate.subscribe.json create mode 100644 special-pages/messages/new-tab/favorites_openContextMenu.notify.json create mode 100644 special-pages/messages/new-tab/favorites_setConfig.notify.json create mode 100644 special-pages/messages/new-tab/types/favorites-config.json create mode 100644 special-pages/messages/new-tab/types/favorites-data.json create mode 100644 special-pages/pages/new-tab/app/favorites/Favorites.module.css create mode 100644 special-pages/pages/new-tab/app/favorites/FavoritesProvider.js create mode 100644 special-pages/pages/new-tab/app/favorites/FavouritesGrid.js create mode 100644 special-pages/pages/new-tab/app/favorites/Tile.js create mode 100644 special-pages/pages/new-tab/app/favorites/favorites.service.js create mode 100644 special-pages/pages/new-tab/app/favorites/mocks/MockFavoritesProvider.js create mode 100644 special-pages/pages/new-tab/app/favorites/mocks/favorites.data.js diff --git a/package-lock.json b/package-lock.json index 00e94408d..39f6d5b57 100644 --- a/package-lock.json +++ b/package-lock.json @@ -84,6 +84,39 @@ "url": "https://github.com/sponsors/philsturgeon" } }, + "node_modules/@atlaskit/pragmatic-drag-and-drop": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@atlaskit/pragmatic-drag-and-drop/-/pragmatic-drag-and-drop-1.4.0.tgz", + "integrity": "sha512-qRY3PTJIcxfl/QB8Gwswz+BRvlmgAC5pB+J2hL6dkIxgqAgVwOhAamMUKsrOcFU/axG2Q7RbNs1xfoLKDuhoPg==", + "license": "Apache-2.0", + "dependencies": { + "@babel/runtime": "^7.0.0", + "bind-event-listener": "^3.0.0", + "raf-schd": "^4.0.3" + } + }, + "node_modules/@atlaskit/pragmatic-drag-and-drop-hitbox": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@atlaskit/pragmatic-drag-and-drop-hitbox/-/pragmatic-drag-and-drop-hitbox-1.0.3.tgz", + "integrity": "sha512-/Sbu/HqN2VGLYBhnsG7SbRNg98XKkbF6L7XDdBi+izRybfaK1FeMfodPpm/xnBHPJzwYMdkE0qtLyv6afhgMUA==", + "license": "Apache-2.0", + "dependencies": { + "@atlaskit/pragmatic-drag-and-drop": "^1.1.0", + "@babel/runtime": "^7.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.25.7.tgz", + "integrity": "sha512-FjoyLe754PMiYsFaN5C94ttGiOmBNYTf6pLr4xXHAT5uctHb092PBszndLDR5XA/jghQvn4n7JMHl7dmTgbm9w==", + "license": "MIT", + "dependencies": { + "regenerator-runtime": "^0.14.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@canvas/image-data": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@canvas/image-data/-/image-data-1.0.0.tgz", @@ -1581,6 +1614,12 @@ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "dev": true }, + "node_modules/bind-event-listener": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bind-event-listener/-/bind-event-listener-3.0.0.tgz", + "integrity": "sha512-PJvH288AWQhKs2v9zyfYdPzlPqf5bXbGMmhmUIY9x4dAUGIWgomO771oBQNwJnMQSnUIXhKu6sgzpBRXTlvb8Q==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -4439,6 +4478,18 @@ } ] }, + "node_modules/raf-schd": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz", + "integrity": "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==", + "license": "MIT" + }, + "node_modules/regenerator-runtime": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", + "license": "MIT" + }, "node_modules/regexp.prototype.flags": { "version": "1.5.3", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.3.tgz", @@ -5413,6 +5464,8 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "@atlaskit/pragmatic-drag-and-drop": "^1.3.1", + "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.0.3", "@formkit/auto-animate": "^0.8.0", "@rive-app/canvas-single": "^2.21.5", "classnames": "^2.3.2", @@ -5452,6 +5505,33 @@ "js-yaml": "^4.1.0" } }, + "@atlaskit/pragmatic-drag-and-drop": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@atlaskit/pragmatic-drag-and-drop/-/pragmatic-drag-and-drop-1.4.0.tgz", + "integrity": "sha512-qRY3PTJIcxfl/QB8Gwswz+BRvlmgAC5pB+J2hL6dkIxgqAgVwOhAamMUKsrOcFU/axG2Q7RbNs1xfoLKDuhoPg==", + "requires": { + "@babel/runtime": "^7.0.0", + "bind-event-listener": "^3.0.0", + "raf-schd": "^4.0.3" + } + }, + "@atlaskit/pragmatic-drag-and-drop-hitbox": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@atlaskit/pragmatic-drag-and-drop-hitbox/-/pragmatic-drag-and-drop-hitbox-1.0.3.tgz", + "integrity": "sha512-/Sbu/HqN2VGLYBhnsG7SbRNg98XKkbF6L7XDdBi+izRybfaK1FeMfodPpm/xnBHPJzwYMdkE0qtLyv6afhgMUA==", + "requires": { + "@atlaskit/pragmatic-drag-and-drop": "^1.1.0", + "@babel/runtime": "^7.0.0" + } + }, + "@babel/runtime": { + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.25.7.tgz", + "integrity": "sha512-FjoyLe754PMiYsFaN5C94ttGiOmBNYTf6pLr4xXHAT5uctHb092PBszndLDR5XA/jghQvn4n7JMHl7dmTgbm9w==", + "requires": { + "regenerator-runtime": "^0.14.0" + } + }, "@canvas/image-data": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@canvas/image-data/-/image-data-1.0.0.tgz", @@ -6383,6 +6463,11 @@ } } }, + "bind-event-listener": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bind-event-listener/-/bind-event-listener-3.0.0.tgz", + "integrity": "sha512-PJvH288AWQhKs2v9zyfYdPzlPqf5bXbGMmhmUIY9x4dAUGIWgomO771oBQNwJnMQSnUIXhKu6sgzpBRXTlvb8Q==" + }, "brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -8420,6 +8505,16 @@ "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", "dev": true }, + "raf-schd": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz", + "integrity": "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==" + }, + "regenerator-runtime": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" + }, "regexp.prototype.flags": { "version": "1.5.3", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.3.tgz", @@ -8645,6 +8740,8 @@ "special-pages": { "version": "file:special-pages", "requires": { + "@atlaskit/pragmatic-drag-and-drop": "^1.3.1", + "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.0.3", "@duckduckgo/messaging": "*", "@formkit/auto-animate": "^0.8.0", "@playwright/test": "^1.40.1", diff --git a/packages/special-pages/package.json b/packages/special-pages/package.json new file mode 100644 index 000000000..631ab9cde --- /dev/null +++ b/packages/special-pages/package.json @@ -0,0 +1,42 @@ +{ + "name": "special-pages", + "private": "true", + "version": "1.0.0", + "description": "A collection of HTML/CSS/JS pages that can be loaded into privileged environments, like about: pages", + "main": "index.js", + "type": "module", + "scripts": { + "build": "node index.mjs", + "build.dev": "node index.mjs --env development", + "test": "npm run test.unit && playwright test --grep-invert '@screenshots'", + "test.screenshots": "npm run test.unit && playwright test --grep '@screenshots'", + "test.windows": "npm run test -- --project windows", + "test.macos": "npm run test -- --project macos", + "test.ios": "npm run test -- --project ios", + "test.android": "npm run test -- --project android", + "test.headed": "npm run test -- --headed", + "test.ui": "npm run test -- --ui", + "test.unit": "node --test unit-test/* pages/duckplayer/unit-tests/* ", + "pretest": "npm run build.dev", + "pretest.headed": "npm run build.dev", + "test-int-x": "npm run test", + "test-int": "npm run test", + "serve": "http-server -c-1 --port 3210 ../../", + "watch": "chokidar pages shared --initial -c 'npm run build.dev'" + }, + "license": "ISC", + "devDependencies": { + "@duckduckgo/messaging": "*", + "@playwright/test": "^1.40.1", + "http-server": "^14.1.1", + "web-resource-inliner": "^6.0.1" + }, + "dependencies": { + "@atlaskit/pragmatic-drag-and-drop": "^1.3.1", + "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.0.3", + "@formkit/auto-animate": "^0.8.0", + "@rive-app/canvas-single": "^2.21.5", + "preact": "^10.24.2", + "classnames": "^2.3.2" + } +} diff --git a/special-pages/messages/new-tab/examples/stats.js b/special-pages/messages/new-tab/examples/stats.js index 4d23135c7..f6ec1244b 100644 --- a/special-pages/messages/new-tab/examples/stats.js +++ b/special-pages/messages/new-tab/examples/stats.js @@ -14,7 +14,8 @@ const privacyStatsData = { * @type {import("../../../types/new-tab").StatsConfig} */ const minimumConfig = { - expansion: "expanded" + expansion: "expanded", + animation: { kind: "none" } } /** diff --git a/special-pages/messages/new-tab/favorites_getConfig.request.json b/special-pages/messages/new-tab/favorites_getConfig.request.json new file mode 100644 index 000000000..0af74a319 --- /dev/null +++ b/special-pages/messages/new-tab/favorites_getConfig.request.json @@ -0,0 +1,3 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#" +} diff --git a/special-pages/messages/new-tab/favorites_getConfig.response.json b/special-pages/messages/new-tab/favorites_getConfig.response.json new file mode 100644 index 000000000..cba463319 --- /dev/null +++ b/special-pages/messages/new-tab/favorites_getConfig.response.json @@ -0,0 +1,8 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "allOf": [ + { + "$ref": "./types/favorites-config.json" + } + ] +} diff --git a/special-pages/messages/new-tab/favorites_getData.request.json b/special-pages/messages/new-tab/favorites_getData.request.json new file mode 100644 index 000000000..0af74a319 --- /dev/null +++ b/special-pages/messages/new-tab/favorites_getData.request.json @@ -0,0 +1,3 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#" +} diff --git a/special-pages/messages/new-tab/favorites_getData.response.json b/special-pages/messages/new-tab/favorites_getData.response.json new file mode 100644 index 000000000..9f5d7f273 --- /dev/null +++ b/special-pages/messages/new-tab/favorites_getData.response.json @@ -0,0 +1,8 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "allOf": [ + { + "$ref": "types/favorites-data.json" + } + ] +} diff --git a/special-pages/messages/new-tab/favorites_move.notify.json b/special-pages/messages/new-tab/favorites_move.notify.json new file mode 100644 index 000000000..b728005c1 --- /dev/null +++ b/special-pages/messages/new-tab/favorites_move.notify.json @@ -0,0 +1,19 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Favorites Move Action", + "type": "object", + "required": [ + "id", + "targetIndex" + ], + "properties": { + "id": { + "description": "Entity ID", + "type": "string" + }, + "targetIndex": { + "description": "zero-indexed target", + "type": "number" + } + } +} diff --git a/special-pages/messages/new-tab/favorites_onConfigUpdate.subscribe.json b/special-pages/messages/new-tab/favorites_onConfigUpdate.subscribe.json new file mode 100644 index 000000000..cba463319 --- /dev/null +++ b/special-pages/messages/new-tab/favorites_onConfigUpdate.subscribe.json @@ -0,0 +1,8 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "allOf": [ + { + "$ref": "./types/favorites-config.json" + } + ] +} diff --git a/special-pages/messages/new-tab/favorites_onDataUpdate.subscribe.json b/special-pages/messages/new-tab/favorites_onDataUpdate.subscribe.json new file mode 100644 index 000000000..9f5d7f273 --- /dev/null +++ b/special-pages/messages/new-tab/favorites_onDataUpdate.subscribe.json @@ -0,0 +1,8 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "allOf": [ + { + "$ref": "types/favorites-data.json" + } + ] +} diff --git a/special-pages/messages/new-tab/favorites_openContextMenu.notify.json b/special-pages/messages/new-tab/favorites_openContextMenu.notify.json new file mode 100644 index 000000000..977717a80 --- /dev/null +++ b/special-pages/messages/new-tab/favorites_openContextMenu.notify.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Favorites Open Context Menu Action", + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "description": "Entity ID", + "type": "string" + } + } +} diff --git a/special-pages/messages/new-tab/favorites_setConfig.notify.json b/special-pages/messages/new-tab/favorites_setConfig.notify.json new file mode 100644 index 000000000..cba463319 --- /dev/null +++ b/special-pages/messages/new-tab/favorites_setConfig.notify.json @@ -0,0 +1,8 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "allOf": [ + { + "$ref": "./types/favorites-config.json" + } + ] +} diff --git a/special-pages/messages/new-tab/types/favorites-config.json b/special-pages/messages/new-tab/types/favorites-config.json new file mode 100644 index 000000000..ec073b2d5 --- /dev/null +++ b/special-pages/messages/new-tab/types/favorites-config.json @@ -0,0 +1,10 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "FavoritesConfig", + "type": "object", + "required": ["expansion"], + "properties": { + "expansion": { "$ref": "./expansion.json" }, + "animation": { "$ref": "./animation.json" } + } +} diff --git a/special-pages/messages/new-tab/types/favorites-data.json b/special-pages/messages/new-tab/types/favorites-data.json new file mode 100644 index 000000000..e7e146b4c --- /dev/null +++ b/special-pages/messages/new-tab/types/favorites-data.json @@ -0,0 +1,30 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Favorites Data", + "type": "object", + "required": ["favorites"], + "properties": { + "favorites": { + "type": "array", + "items": { + "type": "object", + "title": "Favorite", + "required": ["data", "id", "title", "favicon"], + "properties": { + "data": { + "type": "string" + }, + "id": { + "type": "string" + }, + "title": { + "type": "string" + }, + "favicon": { + "type": "string" + } + } + } + } + } +} diff --git a/special-pages/package.json b/special-pages/package.json index fef570f92..b44343fb0 100644 --- a/special-pages/package.json +++ b/special-pages/package.json @@ -19,7 +19,7 @@ "test.android": "npm run test-int -- --project android", "test.headed": "npm run test-int -- --headed", "test.ui": "npm run test-int -- --ui", - "serve": "http-server -c-1 --port 3210 ../../", + "serve": "http-server -c-1 --port 3210 ../build/integration/pages", "watch": "chokidar pages shared --initial -c 'npm run build.dev'" }, "license": "ISC", @@ -34,6 +34,8 @@ "preact": "^10.24.3", "classnames": "^2.3.2", "@formkit/auto-animate": "^0.8.0", - "@rive-app/canvas-single": "^2.21.5" + "@rive-app/canvas-single": "^2.21.5", + "@atlaskit/pragmatic-drag-and-drop": "^1.3.1", + "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.0.3" } } diff --git a/special-pages/pages/new-tab/app/components/Components.jsx b/special-pages/pages/new-tab/app/components/Components.jsx index 190a4469b..993cc6d0e 100644 --- a/special-pages/pages/new-tab/app/components/Components.jsx +++ b/special-pages/pages/new-tab/app/components/Components.jsx @@ -67,16 +67,17 @@ function Stage({ entries }) { return (
- {id}{" "} - -
- select{" "} +
+ {id} Open 🔗{" "} + +
+
+ isolate{" "} + title="show this component only">select{" "} import("preact").ComponentChild}>} */ export const mainExamples = { @@ -30,6 +33,65 @@ export const mainExamples = { 'stats.heading.none': { factory: () => }, + 'favorites.many': { + factory: () => ( + + ) + }, + 'favorites.few.7': { + factory: () => ( + + ) + }, + 'favorites.few.7.auto-animate': { + factory: () => ( + + ) + }, + 'favorites.few.7.view-transitions': { + factory: () => ( + + ) + }, + 'favorites.few.6': { + factory: () => ( + + ) + }, + 'favorites.few.5': { + factory: () => ( + + ) + }, + 'favorites.multi': { + factory: () => ( +
+ +
+ +
+ +
+ +
+ ) + }, + 'favorites.single': { + factory: () => ( + + ) + }, + 'favorites.none': { + factory: () => ( + + ) + } } export const otherExamples = { diff --git a/special-pages/pages/new-tab/app/docs.js b/special-pages/pages/new-tab/app/docs.js index 4ad13b42a..fd6336dac 100644 --- a/special-pages/pages/new-tab/app/docs.js +++ b/special-pages/pages/new-tab/app/docs.js @@ -7,3 +7,4 @@ */ export * from './privacy-stats/privacy-stats.service.js' export * from './widget-list/widget-config.service.js' +export * from './favorites/favorites.service.js' diff --git a/special-pages/pages/new-tab/app/favorites/Favorites.js b/special-pages/pages/new-tab/app/favorites/Favorites.js index 1c6563c69..a3c53b71c 100644 --- a/special-pages/pages/new-tab/app/favorites/Favorites.js +++ b/special-pages/pages/new-tab/app/favorites/Favorites.js @@ -1,12 +1,269 @@ import { h } from 'preact' +import cn from 'classnames' +import { useAutoAnimate } from '@formkit/auto-animate/preact' + import { useVisibility } from '../widget-list/widget-config.provider.js' +import styles from './Favorites.module.css' +import { useContext, useId, useMemo, useCallback } from 'preact/hooks' +import { TileMemo } from './Tile.js' +import { FavoritesContext, FavoritesProvider } from './FavoritesProvider.js' +import { useGridState } from './FavouritesGrid.js' +import { memo } from 'preact/compat' +import { Chevron } from '../components/Chevron.js' +import { useTypedTranslation } from '../types.js' +import { viewTransition } from '../utils.js' + +/** + * @typedef {import('../../../../types/new-tab').Expansion} Expansion + * @typedef {import('../../../../types/new-tab').Animation} Animation + * @typedef {import('../../../../types/new-tab').Favorite} Favorite + * @typedef {import('../../../../types/new-tab').FavoritesData} FavoritesData + * @typedef {import('../../../../types/new-tab').FavoritesConfig} FavoritesConfig + */ + +/** + * @param {object} props + * @param {Favorite[]} props.favorites + * @param {(list: Favorite[], id: string, toIndex: number) => void} props.listDidReOrder + * @param {(id: string) => void} props.openContextMenu + * @param {Expansion} props.expansion + * @param {() => void} props.toggle + * @param {Animation['kind']} [props.animation] - optionally configure animations + */ +export function Favorites (props) { + if (props.animation === 'auto-animate') { + return + } + if (props.animation === 'view-transitions') { + return + } + + // no animations + return ( + + ) +} + +/** + * @param {object} props + * @param {Favorite[]} props.favorites + * @param {import("preact").ComponentProps['listDidReOrder']} props.listDidReOrder + * @param {Expansion} props.expansion + * @param {() => void} props.toggle + * @param {(id: string) => void} props.openContextMenu + */ +export function WithAutoAnimate (props) { + const [ref] = useAutoAnimate() + return +} + +/** + * @param {object} props + * @param {Favorite[]} props.favorites + * @param {import("preact").ComponentProps['listDidReOrder']} props.listDidReOrder + * @param {Expansion} props.expansion + * @param {() => void} props.toggle + * @param {(id: string) => void} props.openContextMenu + */ +export function WithViewTransitions (props) { + const willToggle = useCallback(() => { + viewTransition(props.toggle) + }, [props.toggle]) + return +} + +/** + * @param {object} props + * @param {import("preact").Ref} [props.gridRef] + * @param {Favorite[]} props.favorites + * @param {import("preact").ComponentProps['listDidReOrder']} props.listDidReOrder + * @param {Expansion} props.expansion + * @param {Animation['kind']} props.animateItems + * @param {() => void} props.toggle + * @param {(id: string) => void} props.openContextMenu + */ +export function FavoritesConfigured ({ gridRef, favorites, listDidReOrder, expansion, toggle, animateItems, openContextMenu }) { + useGridState(favorites, listDidReOrder, animateItems) + + // todo: does this need to be dynamic for smaller screens? + const ROW_CAPACITY = 6 + + // see: https://www.w3.org/WAI/ARIA/apg/patterns/accordion/examples/accordion/ + const WIDGET_ID = useId() + const TOGGLE_ID = useId() + + const ITEM_PREFIX = useId() + const placeholders = calculatePlaceholders(favorites.length, ROW_CAPACITY) + + // only recompute the list + const items = useMemo(() => { + return favorites.map((item) => ( + + )).concat(Array.from({ length: placeholders }).map((_, index) => { + if (index === 0) { + return + } + return ( +
+ ) + })) + }, [favorites, placeholders, ITEM_PREFIX]) + + /** + * @param {MouseEvent} event + */ + function onContextMenu (event) { + let target = /** @type {HTMLElement|null} */(event.target) + while (target && target !== event.currentTarget) { + if (typeof target.dataset.id === 'string') { + event.preventDefault() + event.stopImmediatePropagation() + return openContextMenu(target.dataset.id) + } else { + target = target.parentElement + } + } + } + + return ( +
+
+ {items.slice(0, expansion === 'expanded' ? undefined : ROW_CAPACITY)} +
+ +
+ ) +} + +/** + * Function to handle showing or hiding content based on certain conditions. + * + * @param {Object} props - Input parameters for controlling the behavior of the ShowHide functionality. + * @param {number} props.itemCount - The current count of items to be displayed. + * @param {string} props.expansion - The current state of expansion ('expanded' or 'collapsed'). + * @param {() => void} props.toggle - Callback function to toggle the display state. + * @param {number} props.capacity - The maximum capacity for items to be displayed before hiding. + * @param {import("preact").ComponentProps<'button'>} [props.buttonAttrs] - The maximum capacity for items to be displayed before hiding. + */ +function ShowHide ({ itemCount, expansion, toggle, capacity, buttonAttrs = {} }) { + const { t } = useTypedTranslation() + return ( +
capacity + })} + > +
+
+ +
+
+ ) +} + +const PlusIcon = memo(function PlusIcon () { + const labelId = useId() + return ( +
+ +
+ {'Add Favorite'} +
+
+ ) +}) + +/** + * @param {number} totalItems + * @param {number} itemsPerRow + * @return {number|number} + */ +function calculatePlaceholders (totalItems, itemsPerRow) { + if (totalItems === 0) return itemsPerRow + if (totalItems === itemsPerRow) return 1 + // Calculate how many items are left over in the last row + const itemsInLastRow = totalItems % itemsPerRow + + // If there are leftover items, calculate the placeholders needed to fill the last row + const placeholders = itemsInLastRow > 0 ? itemsPerRow - itemsInLastRow : 0 + + return placeholders +} export function FavoritesCustomized () { - const { id, visibility } = useVisibility() + const { visibility } = useVisibility() if (visibility === 'hidden') { return null } return ( -

Favourites here... (id: {id})

+ + + ) } + +/** + * Component that consumes FavoritesContext for displaying favorites list. + */ +export function FavoritesConsumer () { + const { state, toggle, listDidReOrder, openContextMenu } = useContext(FavoritesContext) + if (state.status === 'ready') { + return ( + + ) + } + return null +} diff --git a/special-pages/pages/new-tab/app/favorites/Favorites.module.css b/special-pages/pages/new-tab/app/favorites/Favorites.module.css new file mode 100644 index 000000000..9d8d3ca51 --- /dev/null +++ b/special-pages/pages/new-tab/app/favorites/Favorites.module.css @@ -0,0 +1,201 @@ +.root { + margin: 0 auto; + display: grid; + grid-template-rows: auto auto; + grid-template-areas: + 'grid' + 'showhide'; +} + +.showhide { + grid-area: showhide; + height: 32px; +} + +.showhideInner { + opacity: 0; + visibility: hidden; + display: grid; + justify-items: center; + grid-template-columns: auto; + grid-template-areas: 'middle'; + grid-template-rows: auto; + align-items: center; +} + +.root { + &:hover { + .showhideVisible .showhideInner { + opacity: 1; + visibility: visible; + } + } +} + +.showhideButton { + cursor: pointer; + background: none; + border: none; + outline: none; + display: flex; + align-items: center; + color: var(--ntp-text-normal); + background: var(--ntp-background-color); + height: 32px; + line-height: 32px; + grid-area: middle; + gap: 6px; + padding-left: 15px; + padding-right: 10px; + font-size: 11px; + + &[aria-pressed=false] svg { + transform: rotate(180deg); + } +} + +.hr { + grid-area: middle; + width: 100%; + height: 1px; + border-color: var(--color-black-at-9); + @media screen and (prefers-color-scheme: dark) { + border-color: var(--color-white-at-9); + } +} + +.grid { + grid-area: grid; +} + +.grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(64px, 1fr)); + align-items: start; + grid-column-gap: 24px; + grid-row-gap: 8px; +} + +.item { + display: grid; + grid-row-gap: 6px; + align-content: center; + justify-content: center; + position: relative; +} + +.icon { + display: grid; + align-content: center; + justify-content: center; + width: 64px; + height: 64px; + border-radius: 12px; +} + +.draggable { + background: var(--color-black-at-3); + + &:hover { + background: var(--color-black-at-9); + } + + &:active { + transform: scale(0.95); + } + + @media screen and (prefers-color-scheme: dark) { + background: var(--color-white-at-9); + &:hover { + background: var(--color-white-at-12); + } + } +} + +.favicon { + display: block; + width: 32px; + height: 32px; + border-radius: 8px; + background-repeat: no-repeat; + background-size: contain; +} + +.text { + text-align: center; + font-size: 10px; + line-height: 13px; + font-weight: 400; + min-height: 2.8em; + overflow: hidden; + text-overflow: ellipsis; + -webkit-line-clamp: 2; + display: -webkit-box; + -webkit-box-orient: vertical; +} +.preview { + padding: .5em; + border-radius: 5px; + background: white; + /*color: black;*/ +} + +.placeholder { + background-color: transparent; + border: 1.5px dashed var(--color-black-at-9); + + @media screen and (prefers-color-scheme: dark) { + border-color: var(--color-white-at-9); + } +} + +.plus { + border-style: solid; + + &:hover { + background: var(--color-black-at-3); + } + + &:active { + transform: scale(0.95); + } + + @media screen and (prefers-color-scheme: dark) { + &:hover { + background: var(--color-white-at-9); + } + } +} + +.dropper { + width: 2px; + height: 64px; + position: absolute; + top: 0; + background-color: var(--color-black-at-12); + @media screen and (prefers-color-scheme: dark) { + background-color: var(--color-white-at-12); + } +} + +.dropper[data-edge="left"] { + left: -13px; +} + +.dropper[data-edge="right"] { + right: -13px; +} + +[data-item-state="idle"] { + &:hover { + border-color: #FFF; + } +} + +[data-item-state="dragging"] { + opacity: 0.4; +} + +[data-item-state="is-dragging-over"] { +} + diff --git a/special-pages/pages/new-tab/app/favorites/FavoritesProvider.js b/special-pages/pages/new-tab/app/favorites/FavoritesProvider.js new file mode 100644 index 000000000..1d4a89f0f --- /dev/null +++ b/special-pages/pages/new-tab/app/favorites/FavoritesProvider.js @@ -0,0 +1,104 @@ +import { createContext, h } from 'preact' +import { InstanceIdContext } from './FavouritesGrid.js' +import { useCallback, useEffect, useReducer, useRef, useState } from 'preact/hooks' +import { FavoritesService } from './favorites.service.js' +import { useMessaging } from '../types.js' +import { reducer, useConfigSubscription, useDataSubscription, useInitialData } from '../service.hooks.js' + +/** + * @typedef {import('../../../../types/new-tab.js').Favorite} Favorite + * @typedef {import('../../../../types/new-tab.js').FavoritesData} FavoritesData + * @typedef {import('../../../../types/new-tab.js').FavoritesConfig} FavoritesConfig + * @typedef {import('../service.hooks.js').State} State + * @typedef {import('../service.hooks.js').Events} Events + */ + +/** + * These are the values exposed to consumers. + */ +export const FavoritesContext = createContext({ + /** @type {import('../service.hooks.js').State} */ + state: { status: 'idle', data: null, config: null }, + /** @type {() => void} */ + toggle: () => { + throw new Error('must implement') + }, + /** @type {(list: Favorite[], id: string, targetIndex: number) => void} */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + listDidReOrder: (list, id, targetIndex) => { + throw new Error('must implement') + }, + /** @type {(id: string) => void} */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + openContextMenu: (id) => { + throw new Error('must implement') + } +}) + +export const FavoritesDispatchContext = createContext(/** @type {import("preact/hooks").Dispatch} */({})) + +function getInstanceId () { + return Symbol('instance-id') +} + +/** + * @param {object} props + * @param {import("preact").ComponentChild} props.children + */ +export function FavoritesProvider ({ children }) { + const initial = /** @type {State} */({ + status: /** @type {const} */('idle'), + data: null, + config: null + }) + + const [state, dispatch] = useReducer(reducer, initial) + + const [instanceId] = useState(getInstanceId) + + const service = useService() + + // get initial data + useInitialData({ dispatch, service }) + + // subscribe to data updates + useDataSubscription({ dispatch, service }) + + // subscribe to toggle + expose a fn for sync toggling + const { toggle } = useConfigSubscription({ dispatch, service }) + + /** @type {(f: Favorite[], id: string, targetIndex: number) => void} */ + const listDidReOrder = useCallback((favorites, id, targetIndex) => { + if (!service.current) return + service.current.setFavoritesOrder({ favorites }, id, targetIndex) + }, [service]) + + /** @type {(id: string) => void} */ + const openContextMenu = useCallback((id) => { + if (!service.current) return + service.current.openContextMenu(id) + }, [service]) + + return ( + + + + {children} + + + + ) +} + +export function useService () { + const service = useRef(/** @type {FavoritesService | null} */(null)) + const ntp = useMessaging() + useEffect(() => { + const stats = new FavoritesService(ntp) + service.current = stats + return () => { + stats.destroy() + } + }, [ntp]) + return service +} diff --git a/special-pages/pages/new-tab/app/favorites/FavouritesGrid.js b/special-pages/pages/new-tab/app/favorites/FavouritesGrid.js new file mode 100644 index 000000000..c8da13e32 --- /dev/null +++ b/special-pages/pages/new-tab/app/favorites/FavouritesGrid.js @@ -0,0 +1,93 @@ +import { createContext } from 'preact' +import { useContext, useEffect } from 'preact/hooks' +import { flushSync } from 'preact/compat' + +import { monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter' +import { extractClosestEdge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge' +import { reorderWithEdge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/util/reorder-with-edge' +import { getReorderDestinationIndex } from '@atlaskit/pragmatic-drag-and-drop-hitbox/util/get-reorder-destination-index' +import { viewTransition } from '../utils.js' + +/** + * @typedef {import('../../../../types/new-tab').FavoritesData} FavoritesData + * @typedef {import('../../../../types/new-tab').Favorite} Favorite + * @typedef {import('../../../../types/new-tab').Animation} Animation + * @typedef {import('../../../../types/new-tab').FavoritesConfig} FavoritesConfig + */ + +function getInstanceId () { + return Symbol('instance-id') +} + +/** @type {import("preact").Context} */ +export const InstanceIdContext = createContext(getInstanceId()) + +/** + * @param {Favorite[]} favorites + * @param {import("preact").ComponentProps['listDidReOrder']} setFavorites + * @param {Animation['kind']} animation + */ +export function useGridState (favorites, setFavorites, animation) { + const instanceId = useContext(InstanceIdContext) + useEffect(() => { + return monitorForElements({ + canMonitor ({ source }) { + return source.data.instanceId === instanceId + }, + onDrop ({ source, location }) { + const target = location.current.dropTargets[0] + if (!target) { + return + } + const destinationSrc = target.data.url + const startSrc = source.data.url + const startId = source.data.id + + if (typeof startId !== 'string') { + return console.warn('could not access the id') + } + + if (typeof destinationSrc !== 'string') { + return console.warn('could not access the destinationSrc') + } + + if (typeof startSrc !== 'string') { + return console.warn('could not access the startSrc') + } + + const startIndex = favorites.findIndex(item => item.data === startSrc) + const indexOfTarget = favorites.findIndex(item => item.data === destinationSrc) + + const closestEdgeOfTarget = extractClosestEdge(target.data) + + // where should the element be inserted? + // we only use this value to send to the native side + const targetIndex = getReorderDestinationIndex({ + closestEdgeOfTarget, + startIndex, + indexOfTarget, + axis: 'horizontal' + }) + + // reorder the list using the helper from the dnd lib + const reorderedList = reorderWithEdge({ + list: favorites, + startIndex, + indexOfTarget, + closestEdgeOfTarget, + axis: 'horizontal' + }) + + if (animation === 'view-transitions') { + viewTransition(() => { + flushSync(() => { + setFavorites(reorderedList, startId, targetIndex) + }) + }) + } else { + setFavorites(reorderedList, startId, targetIndex) + } + } + }) + }, [instanceId, favorites, animation]) +} diff --git a/special-pages/pages/new-tab/app/favorites/Tile.js b/special-pages/pages/new-tab/app/favorites/Tile.js new file mode 100644 index 000000000..d919f9c66 --- /dev/null +++ b/special-pages/pages/new-tab/app/favorites/Tile.js @@ -0,0 +1,139 @@ +import { Fragment, h } from 'preact' +import cn from 'classnames' +import { useContext, useEffect, useRef, useState } from 'preact/hooks' +import { createPortal, memo } from 'preact/compat' +import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine' +import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter' +import { attachClosestEdge, extractClosestEdge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge' + +import styles from './Favorites.module.css' +import { InstanceIdContext } from './FavouritesGrid.js' + +/** + * @typedef {{ type: 'idle' } + * | { type: 'dragging' } + * | { type: 'preview'; container: HTMLElement } + * | { type: 'is-dragging-over'; closestEdge: null | import("@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge").Edge } + * } DNDState + * + * @typedef {import('../../../../types/new-tab').Favorite} Favorite + * @typedef {import('../../../../types/new-tab').FavoritesData} FavoritesData + * @typedef {import('../../../../types/new-tab').FavoritesConfig} FavoritesConfig + */ + +/** + * @param {object} props + * @param {Favorite['data']} props.data + * @param {Favorite['id']} props.id + * @param {Favorite['title']} props.title + * @param {Favorite['favicon']} props.favicon + * @param {string} props.prefix - unique id for the parent + */ +export function Tile ({ data, favicon, title, id, prefix }) { + const { state, ref } = useTileState(data, id) + + return ( + +
+
+ {/* */} + +
+
+ {title} +
+ {state.type === 'is-dragging-over' && state.closestEdge + ? ( +
+ ) + : null} +
+ {state.type === 'preview' && state.container + ? createPortal(, state.container) + : null + } +
+ ) +} + +/** + * @param {string} url + * @param {string} id + * @return {{ ref: import("preact").Ref; state: DNDState }} + */ +function useTileState (url, id) { + /** @type {import("preact").Ref} */ + const ref = useRef(null) + const [state, setState] = useState(/** @type {DNDState} */({ type: 'idle' })) + const instanceId = useContext(InstanceIdContext) + + useEffect(() => { + const el = ref.current + if (!el) throw new Error('unreachable') + + return combine( + draggable({ + element: el, + getInitialData: () => ({ type: 'grid-item', url, id, instanceId }), + onDragStart: () => setState({ type: 'dragging' }), + onDrop: () => setState({ type: 'idle' }) + }), + dropTargetForElements({ + element: el, + getData: ({ input }) => { + return attachClosestEdge({ url, id }, { + element: el, + input, + allowedEdges: ['left', 'right'] + }) + }, + getIsSticky: () => true, + canDrop: ({ source }) => { + return source.data.instanceId === instanceId && + source.data.type === 'grid-item' && + source.data.id !== id + }, + onDragEnter: ({ self }) => { + const closestEdge = extractClosestEdge(self.data) + setState({ type: 'is-dragging-over', closestEdge }) + }, + onDrag ({ self }) { + const closestEdge = extractClosestEdge(self.data) + // Only need to update react state if nothing has changed. + // Prevents re-rendering. + setState((current) => { + if (current.type === 'is-dragging-over' && current.closestEdge === closestEdge) { + return current + } + return { type: 'is-dragging-over', closestEdge } + }) + }, + onDragLeave: () => setState({ type: 'idle' }), + onDrop: () => setState({ type: 'idle' }) + }) + ) + }, [instanceId, url, id]) + + return { ref, state } +} + +export const TileMemo = memo(Tile) + +/** + * @param {object} props + * @param {string} props.data + * @param {string} props.title + */ +function DragPreview ({ data, title }) { + return
+

{title}

+

{data}

+
+} diff --git a/special-pages/pages/new-tab/app/favorites/favorites.service.js b/special-pages/pages/new-tab/app/favorites/favorites.service.js new file mode 100644 index 000000000..691099db8 --- /dev/null +++ b/special-pages/pages/new-tab/app/favorites/favorites.service.js @@ -0,0 +1,163 @@ +import { Service } from '../service.js' + +/** + * @typedef {import("../../../../types/new-tab.js").FavoritesData} FavoritesData + * @typedef {import("../../../../types/new-tab.js").Favorite} Favorite + * @typedef {import("../../../../types/new-tab.js").FavoritesConfig} FavoritesConfig + */ + +/** + * Public API for the Favorites Widget. + * + * ## Requests: + * - {@link "NewTab Messages".FavoritesGetDataRequest `favorites_getData`} + * - Used to fetch the initial data (during the first render) + * - returns {@link "NewTab Messages".FavoritesData} + * - {@link "NewTab Messages".FavoritesGetDataRequest `favorites_getConfig`} + * - Used to fetch the initial data (during the first render) + * - returns {@link "NewTab Messages".FavoritesConfig} + * + * + * ## Subscriptions: + * - {@link "NewTab Messages".FavoritesOnDataUpdateSubscription `favorites_onDataUpdate`}. + * - The tracker/company data used in the feed. + * - returns {@link "NewTab Messages".FavoritesData} + * - {@link "NewTab Messages".FavoritesOnConfigUpdateSubscription `favorites_onConfigUpdate`}. + * - The widget config + * - returns {@link "NewTab Messages".FavoritesConfig} + * + * + * ## Notifications: + * - {@link "NewTab Messages".FavoritesSetConfigNotification `favorites_setConfig`} + * - Sent when the user toggles the expansion of the favorites + * - Sends {@link "NewTab Messages".FavoritesConfig} + * - Example payload: + * ```json + * { + * "expansion": "collapsed" + * } + * ``` + * - {@link "NewTab Messages".FavoritesMoveNotification `favorites_move`} + * - Sends {@link "NewTab Messages".FavoritesMoveAction} + * - When you receive this message, apply the following + * - Search your collection to find the object with the given `id`. + * - Remove that object from its current position. + * - Insert it into the new position specified by `targetIndex`. + * - Example payload: + * ```json + * { + * "id": "abc", + * "targetIndex": 1 + * } + * ``` + * - {@link "NewTab Messages".FavoritesOpenContextMenuNotification `favorites_openContextMenu`} + * - Sends {@link "NewTab Messages".FavoritesOpenContextMenuAction} + * - When you receive this message, show the context menu for the entity + * - Example payload: + * ```json + * { + * "id": "abc", + * } + * ``` + */ +export class FavoritesService { + /** + * @param {import("../../src/js/index.js").NewTabPage} ntp - The internal data feed, expected to have a `subscribe` method. + * @internal + */ + constructor (ntp) { + this.ntp = ntp + + /** @type {Service} */ + this.dataService = new Service({ + initial: () => ntp.messaging.request('favorites_getData'), + subscribe: (cb) => ntp.messaging.subscribe('favorites_onDataUpdate', cb) + }) + + /** @type {Service} */ + this.configService = new Service({ + initial: () => ntp.messaging.request('favorites_getConfig'), + subscribe: (cb) => ntp.messaging.subscribe('favorites_onConfigUpdate', cb), + persist: (data) => ntp.messaging.notify('favorites_setConfig', data) + }) + } + + /** + * @returns {Promise<{data: FavoritesData; config: FavoritesConfig}>} + * @internal + */ + async getInitial () { + const p1 = this.configService.fetchInitial() + const p2 = this.dataService.fetchInitial() + const [config, data] = await Promise.all([p1, p2]) + return { config, data } + } + + /** + * @internal + */ + destroy () { + this.configService.destroy() + this.dataService.destroy() + } + + /** + * @param {(evt: {data: FavoritesData, source: 'manual' | 'subscription'}) => void} cb + * @internal + */ + onData (cb) { + return this.dataService.onData(cb) + } + + /** + * @param {(evt: {data: FavoritesConfig, source: 'manual' | 'subscription'}) => void} cb + * @internal + */ + onConfig (cb) { + return this.configService.onData(cb) + } + + /** + * Update the in-memory data immediate and persist. + * Any state changes will be broadcast to consumers synchronously + * @internal + */ + toggleExpansion () { + this.configService.update(old => { + if (old.expansion === 'expanded') { + return { ...old, expansion: /** @type {const} */('collapsed') } + } else { + return { ...old, expansion: /** @type {const} */('expanded') } + } + }) + } + + /** + * @param {FavoritesData} data + * @param {string} id - entity id to move + * @param {number} targetIndex - target index + * @internal + */ + setFavoritesOrder (data, id, targetIndex) { + // update in memory instantly - this will broadcast changes to all listeners + // eslint-disable-next-line @typescript-eslint/no-unused-vars + this.dataService.update((_old) => { + return data + }) + + // then let the native side know about it + this.ntp.messaging.notify('favorites_move', { + id, + targetIndex + }) + } + + /** + * @param {string} id - entity id + * @internal + */ + openContextMenu (id) { + // let the native side know too + this.ntp.messaging.notify('favorites_openContextMenu', { id }) + } +} diff --git a/special-pages/pages/new-tab/app/favorites/mocks/MockFavoritesProvider.js b/special-pages/pages/new-tab/app/favorites/mocks/MockFavoritesProvider.js new file mode 100644 index 000000000..9ae58055b --- /dev/null +++ b/special-pages/pages/new-tab/app/favorites/mocks/MockFavoritesProvider.js @@ -0,0 +1,76 @@ +import { h } from 'preact' +import { InstanceIdContext } from '../FavouritesGrid.js' +import { FavoritesContext, FavoritesDispatchContext } from '../FavoritesProvider.js' +import { useCallback, useReducer, useState } from 'preact/hooks' +import { useEnv } from '../../../../../shared/components/EnvironmentProvider.js' +import { favorites } from './favorites.data.js' +import { reducer } from '../../service.hooks.js' + +/** + * @typedef {import('../../../../../types/new-tab').Favorite} Favorite + * @typedef {import('../../../../../types/new-tab').FavoritesData} FavoritesData + * @typedef {import('../../../../../types/new-tab').FavoritesConfig} FavoritesConfig + * @typedef {import('../../service.hooks.js').State} State + * @typedef {import('../../service.hooks.js').Events} Events + */ + +function getInstanceId () { + return Symbol('instance-id') +} + +/** @type {FavoritesConfig} */ +const DEFAULT_CONFIG = { + expansion: 'expanded' +} + +/** + * @param {object} props + * @param {import("preact").ComponentChild} props.children + * @param {FavoritesData} [props.data] + * @param {FavoritesConfig} [props.config] + */ +export function MockFavoritesProvider ({ + data = favorites.many, + config = DEFAULT_CONFIG, + children +}) { + const { isReducedMotion } = useEnv() + + const [instanceId] = useState(getInstanceId) + + const initial = /** @type {State} */({ + status: 'ready', + data, + config + }) + + /** @type {[State, import('preact/hooks').Dispatch]} */ + const [state, dispatch] = useReducer(reducer, initial) + + const toggle = useCallback(() => { + if (state.status !== 'ready') return + if (state.config.expansion === 'expanded') { + dispatch({ kind: 'config', config: { ...state.config, expansion: 'collapsed' } }) + } else { + dispatch({ kind: 'config', config: { ...state.config, expansion: 'expanded' } }) + } + }, [state.status, state.config?.expansion, isReducedMotion]) + + const listDidReOrder = useCallback((/** @type {Favorite[]} */newList) => { + dispatch({ kind: 'data', data: { favorites: newList } }) + }, []) + + const openContextMenu = () => { + /* no-op */ + } + + return ( + + + + {children} + + + + ) +} diff --git a/special-pages/pages/new-tab/app/favorites/mocks/favorites.data.js b/special-pages/pages/new-tab/app/favorites/mocks/favorites.data.js new file mode 100644 index 000000000..2dd3f82d8 --- /dev/null +++ b/special-pages/pages/new-tab/app/favorites/mocks/favorites.data.js @@ -0,0 +1,52 @@ +/** + * @typedef {import('../../../../../types/new-tab').Favorite} Favorite + * @typedef {import('../../../../../types/new-tab').FavoritesData} FavoritesData + * @typedef {import('../../../../../types/new-tab').FavoritesConfig} FavoritesConfig + */ + +/** + * @type {{ + * many: {favorites: Favorite[]}; + * single: {favorites: Favorite[]}; + * none: {favorites: Favorite[]}; + * two: {favorites: Favorite[]}; + * }} + */ +export const favorites = { + many: { + /** @type {Favorite[]} */ + favorites: [ + { id: 'id-many-1', data: 'https://1.example.com', title: 'Amazon', favicon: './company-icons/amazon.svg' }, + { id: 'id-many-2', data: 'https://2.example.com', title: 'Adform', favicon: './company-icons/adform.svg' }, + { id: 'id-many-3', data: 'https://3.example.com', title: 'Adobe', favicon: './company-icons/adobe.svg' }, + { id: 'id-many-4', data: 'https://4.example.com', title: 'Gmail', favicon: './company-icons/google.svg' }, + { id: 'id-many-5', data: 'https://5.example.com', title: 'TikTok', favicon: './company-icons/bytedance.svg' }, + { id: 'id-many-6', data: 'https://6.example.com', title: 'yeti', favicon: './company-icons/d.svg' }, + { id: 'id-many-7', data: 'https://7.example.com', title: 'Facebook', favicon: './company-icons/facebook.svg' }, + { id: 'id-many-8', data: 'https://8.example.com', title: 'Beeswax', favicon: './company-icons/beeswax.svg' }, + { id: 'id-many-9', data: 'https://9.example.com', title: 'Adobe', favicon: './company-icons/adobe.svg' }, + { id: 'id-many-10', data: 'https://10.example.com', title: 'Beeswax', favicon: './company-icons/beeswax.svg' }, + { id: 'id-many-11', data: 'https://11.example.com', title: 'Facebook', favicon: './company-icons/facebook.svg' }, + { id: 'id-many-12', data: 'https://12.example.com', title: 'Gmail', favicon: './company-icons/google.svg' }, + { id: 'id-many-13', data: 'https://13.example.com', title: 'TikTok', favicon: './company-icons/bytedance.svg' }, + { id: 'id-many-14', data: 'https://14.example.com', title: 'yeti', favicon: './company-icons/d.svg' } + ] + }, + two: { + /** @type {Favorite[]} */ + favorites: [ + { id: 'id-two-1', data: 'https://1.example.com', title: 'Amazon', favicon: './company-icons/amazon.svg' }, + { id: 'id-two-2', data: 'https://2.example.com', title: 'Adform', favicon: './company-icons/adform.svg' } + ] + }, + single: { + /** @type {Favorite[]} */ + favorites: [ + { id: 'id-single-1', data: 'https://1.example.com', title: 'Amazon', favicon: './company-icons/amazon.svg' } + ] + }, + none: { + /** @type {Favorite[]} */ + favorites: [] + } +} diff --git a/special-pages/pages/new-tab/app/service.js b/special-pages/pages/new-tab/app/service.js index ab320cdad..2a6ba51a8 100644 --- a/special-pages/pages/new-tab/app/service.js +++ b/special-pages/pages/new-tab/app/service.js @@ -147,7 +147,7 @@ export class Service { // some services will not implement persistence if (!this.impl.persist) return - // if the data never read, there's nothing to persist + // if the data was never set, there's nothing to persist if (this.data === null) return // send the data diff --git a/special-pages/pages/new-tab/src/js/mock-transport.js b/special-pages/pages/new-tab/src/js/mock-transport.js index ff7903fc8..a385e897e 100644 --- a/special-pages/pages/new-tab/src/js/mock-transport.js +++ b/special-pages/pages/new-tab/src/js/mock-transport.js @@ -1,8 +1,12 @@ import { TestTransportConfig } from '@duckduckgo/messaging' import { stats } from '../../app/privacy-stats/mocks/stats.js' +import { favorites } from '../../app/favorites/mocks/favorites.data.js' /** + * @typedef {import('../../../../types/new-tab').Favorite} Favorite + * @typedef {import('../../../../types/new-tab').FavoritesData} FavoritesData + * @typedef {import('../../../../types/new-tab').FavoritesConfig} FavoritesConfig * @typedef {import('../../../../types/new-tab').StatsConfig} StatsConfig */ @@ -64,10 +68,36 @@ export function mockTransport () { } case 'stats_setConfig': { if (!msg.params) throw new Error('unreachable') - write('stats_config', msg.params) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { animation, ...rest } = msg.params + write('stats_config', rest) broadcast('stats_config') return } + case 'favorites_setConfig': { + if (!msg.params) throw new Error('unreachable') + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { animation, ...rest } = msg.params + write('favorites_config', rest) + broadcast('favorites_config') + return + } + case 'favorites_move': { + if (!msg.params) throw new Error('unreachable') + const { id, targetIndex } = msg.params + const data = read('favorites_data') + + const favorites = reorderArray(data.favorites, id, targetIndex) + + write('favorites_data', { favorites }) + broadcast('favorites_data') + return + } + case 'favorites_openContextMenu': { + if (!msg.params) throw new Error('unreachable') + console.log('mock: ignoring favorites_openContextMenu', msg.params) + return + } default: { console.warn('unhandled notification', msg) } @@ -102,6 +132,31 @@ export function mockTransport () { }, { signal: controller.signal }) return () => controller.abort() } + case 'favorites_onDataUpdate': { + const controller = new AbortController() + channel.addEventListener('message', (msg) => { + if (msg.data.change === 'favorites_data') { + const values = read('favorites_data') + if (values) { + cb(values) + cb(values) + } + } + }, { signal: controller.signal }) + return () => controller.abort() + } + case 'favorites_onConfigUpdate': { + const controller = new AbortController() + channel.addEventListener('message', (msg) => { + if (msg.data.change === 'favorites_config') { + const values = read('favorites_config') + if (values) { + cb(values) + } + } + }, { signal: controller.signal }) + return () => controller.abort() + } } return () => {} }, @@ -126,6 +181,26 @@ export function mockTransport () { } return Promise.resolve(fromStorage) } + case 'favorites_getData': { + const fromStorage = read('favorites_data') + if (!fromStorage) { + write('favorites_data', favorites.many) + return Promise.resolve(favorites.many) + } + return Promise.resolve(fromStorage) + } + case 'favorites_getConfig': { + /** @type {FavoritesConfig} */ + const defaultConfig = { expansion: 'expanded', animation: { kind: 'auto-animate' } } + const fromStorage = read('favorites_config') || defaultConfig + if (url.searchParams.get('animation') === 'none') { + fromStorage.animation = { kind: 'none' } + } + if (url.searchParams.get('animation') === 'view-transitions') { + fromStorage.animation = { kind: 'view-transitions' } + } + return Promise.resolve(fromStorage) + } case 'initialSetup': { const widgetsFromStorage = read('widgets') || [ { id: 'favorites' }, @@ -155,3 +230,17 @@ export function mockTransport () { } }) } + +/** + * @template {{id: string}} T + * @param {T[]} array + * @param {string} id + * @param {number} toIndex + * @return {T[]} + */ +function reorderArray (array, id, toIndex) { + const fromIndex = array.findIndex(item => item.id === id) + const element = array.splice(fromIndex, 1)[0] // Remove the element from the original position + array.splice(toIndex, 0, element) // Insert the element at the new position + return array +} diff --git a/special-pages/pages/new-tab/src/locales/en/newtab.json b/special-pages/pages/new-tab/src/locales/en/newtab.json index e3038c6e6..9b04a54e1 100644 --- a/special-pages/pages/new-tab/src/locales/en/newtab.json +++ b/special-pages/pages/new-tab/src/locales/en/newtab.json @@ -40,5 +40,13 @@ "trackerStatsToggleLabel": { "title": "Show recent activity", "note": "The aria-label text for a toggle button that shows/hides the detailed feed" + }, + "favorites_show_less": { + "title": "Show less", + "note": "" + }, + "favorites_show_more": { + "title": "Show more", + "note": "" } } diff --git a/special-pages/playwright.config.js b/special-pages/playwright.config.js index 19e337414..6b04304cc 100644 --- a/special-pages/playwright.config.js +++ b/special-pages/playwright.config.js @@ -18,6 +18,7 @@ export default defineConfig({ { name: 'integration', testMatch: [ + 'new-tab-favorites.spec.js', 'new-tab-widgets.spec.js', 'new-tab.spec.js' ], diff --git a/special-pages/tests/new-tab-widgets.spec.js b/special-pages/tests/new-tab-widgets.spec.js index a0e7b68f6..6bd21078e 100644 --- a/special-pages/tests/new-tab-widgets.spec.js +++ b/special-pages/tests/new-tab-widgets.spec.js @@ -1,11 +1,27 @@ import { test, expect } from '@playwright/test' import { NewtabPage } from './page-objects/newtab' +test.describe('newtab favorites', () => { + test('fetches config + favorites data', async ({ page }, workerInfo) => { + const ntp = NewtabPage.create(page, workerInfo) + await ntp.reducedMotion() + await ntp.openPage() + const calls1 = await ntp.mocks.waitForCallCount({ method: 'initialSetup', count: 1 }) + const calls2 = await ntp.mocks.waitForCallCount({ method: 'favorites_getConfig', count: 1 }) + const calls3 = await ntp.mocks.waitForCallCount({ method: 'favorites_getData', count: 1 }) + + expect(calls1.length).toBe(1) + expect(calls2.length).toBe(1) + expect(calls3.length).toBe(1) + }) +}) + test.describe('newtab privacy stats', () => { test('fetches config + stats', async ({ page }, workerInfo) => { const ntp = NewtabPage.create(page, workerInfo) await ntp.reducedMotion() await ntp.openPage() + const calls1 = await ntp.mocks.waitForCallCount({ method: 'initialSetup', count: 1 }) const calls2 = await ntp.mocks.waitForCallCount({ method: 'stats_getData', count: 1 }) const calls3 = await ntp.mocks.waitForCallCount({ method: 'stats_getConfig', count: 1 }) diff --git a/special-pages/types/new-tab.ts b/special-pages/types/new-tab.ts index c55d5cd21..798a226e3 100644 --- a/special-pages/types/new-tab.ts +++ b/special-pages/types/new-tab.ts @@ -33,12 +33,81 @@ export type Widgets = WidgetListItem[]; */ export interface NewTabMessages { notifications: + | FavoritesMoveNotification + | FavoritesOpenContextMenuNotification + | FavoritesSetConfigNotification | ReportInitExceptionNotification | ReportPageExceptionNotification | StatsSetConfigNotification | WidgetsSetConfigNotification; - requests: InitialSetupRequest | StatsGetConfigRequest | StatsGetDataRequest; - subscriptions: StatsOnConfigUpdateSubscription | StatsOnDataUpdateSubscription | WidgetsOnConfigUpdatedSubscription; + requests: + | FavoritesGetConfigRequest + | FavoritesGetDataRequest + | InitialSetupRequest + | StatsGetConfigRequest + | StatsGetDataRequest; + subscriptions: + | FavoritesOnConfigUpdateSubscription + | FavoritesOnDataUpdateSubscription + | StatsOnConfigUpdateSubscription + | StatsOnDataUpdateSubscription + | WidgetsOnConfigUpdatedSubscription; +} +/** + * Generated from @see "../messages/new-tab/favorites_move.notify.json" + */ +export interface FavoritesMoveNotification { + method: "favorites_move"; + params: FavoritesMoveAction; +} +export interface FavoritesMoveAction { + /** + * Entity ID + */ + id: string; + /** + * zero-indexed target + */ + targetIndex: number; +} +/** + * Generated from @see "../messages/new-tab/favorites_openContextMenu.notify.json" + */ +export interface FavoritesOpenContextMenuNotification { + method: "favorites_openContextMenu"; + params: FavoritesOpenContextMenuAction; +} +export interface FavoritesOpenContextMenuAction { + /** + * Entity ID + */ + id: string; +} +/** + * Generated from @see "../messages/new-tab/favorites_setConfig.notify.json" + */ +export interface FavoritesSetConfigNotification { + method: "favorites_setConfig"; + params: FavoritesConfig; +} +export interface FavoritesConfig { + expansion: Expansion; + animation?: Animation; +} +export interface None { + kind: "none"; +} +/** + * Use CSS view transitions where available + */ +export interface ViewTransitions { + kind: "view-transitions"; +} +/** + * Use the auto-animate library to provide default animation styles + */ +export interface Auto { + kind: "auto-animate"; } /** * Generated from @see "../messages/new-tab/reportInitException.notify.json" @@ -71,21 +140,6 @@ export interface StatsConfig { expansion: Expansion; animation?: Animation; } -export interface None { - kind: "none"; -} -/** - * Use CSS view transitions where available - */ -export interface ViewTransitions { - kind: "view-transitions"; -} -/** - * Use the auto-animate library to provide default animation styles - */ -export interface Auto { - kind: "auto-animate"; -} /** * Generated from @see "../messages/new-tab/widgets_setConfig.notify.json" */ @@ -100,6 +154,29 @@ export interface WidgetConfigItem { id: string; visibility: WidgetVisibility; } +/** + * Generated from @see "../messages/new-tab/favorites_getConfig.request.json" + */ +export interface FavoritesGetConfigRequest { + method: "favorites_getConfig"; + result: FavoritesConfig; +} +/** + * Generated from @see "../messages/new-tab/favorites_getData.request.json" + */ +export interface FavoritesGetDataRequest { + method: "favorites_getData"; + result: FavoritesData; +} +export interface FavoritesData { + favorites: Favorite[]; +} +export interface Favorite { + data: string; + id: string; + title: string; + favicon: string; +} /** * Generated from @see "../messages/new-tab/initialSetup.request.json" */ @@ -147,6 +224,20 @@ export interface TrackerCompany { displayName: string; count: number; } +/** + * Generated from @see "../messages/new-tab/favorites_onConfigUpdate.subscribe.json" + */ +export interface FavoritesOnConfigUpdateSubscription { + subscriptionEvent: "favorites_onConfigUpdate"; + params: FavoritesConfig; +} +/** + * Generated from @see "../messages/new-tab/favorites_onDataUpdate.subscribe.json" + */ +export interface FavoritesOnDataUpdateSubscription { + subscriptionEvent: "favorites_onDataUpdate"; + params: FavoritesData; +} /** * Generated from @see "../messages/new-tab/stats_onConfigUpdate.subscribe.json" */