diff --git a/demo/scss/content-tree.scss b/demo/scss/content-tree.scss index e470641..852235b 100644 --- a/demo/scss/content-tree.scss +++ b/demo/scss/content-tree.scss @@ -12,4 +12,8 @@ color: inherit; } } + + i { + font-size: var(--size-3); + } } diff --git a/demo/src/core/router.js b/demo/src/core/router.js index 7d3934b..04c5149 100644 --- a/demo/src/core/router.js +++ b/demo/src/core/router.js @@ -30,11 +30,35 @@ import { Minimatch } from 'minimatch'; * @class */ class Router extends EventTarget { - #fallback; + /** + * The fallback route to be used in case no matching route is found. + * @private + * @type {string|null} + */ + #defaultPath = null; + + /** + * The currently active route. + * @private + * @type {object|null} + */ #currentRoute = null; + + /** + * The query parameters associated with the current route. + * @private + * @type {object} + */ #currentQueryParams = {}; + + /** + * An array containing registered route objects with their patterns and associated actions. + * @private + * @type {Array<{ path: Minimatch, start: function, destroy: function }>} + */ #routes = []; + constructor() { super(); @@ -48,7 +72,7 @@ class Router extends EventTarget { const queryParams = Object.fromEntries(url.searchParams.entries()); window.history.pushState({}, '', url.href); - this.handleRouteChange(path, queryParams); + this.#handleRouteChange(path, queryParams); }); // Event listener for the popstate event @@ -56,7 +80,7 @@ class Router extends EventTarget { const entries = new URL(window.location.href).searchParams.entries(); const queryParams = Object.fromEntries(entries); - this.handleRouteChange(window.location.pathname, queryParams); + this.#handleRouteChange(window.location.pathname, queryParams, true); }); } @@ -67,7 +91,8 @@ class Router extends EventTarget { * @param {function} start - The function to be called when the route is navigated to. * @param {function} destroy - The function to be called when the route is navigated away from. */ - addRoute(pattern, start, destroy = () => {}) { + addRoute(pattern, start, destroy = () => { + }) { const path = new Minimatch(pattern, { matchBase: true }); this.#routes.push({ path, start, destroy }); @@ -98,7 +123,7 @@ class Router extends EventTarget { * Navigates to the specified path. * * @param {string} path - The path to navigate to. - * @param {object} queryParams - Optional query parameters to be associated with the route. + * @param {object} [queryParams={}] - (Optional) query parameters to be associated with the route. */ navigateTo(path, queryParams = {}) { const url = new URL(window.location.href); @@ -107,9 +132,14 @@ class Router extends EventTarget { url.search = new URLSearchParams(queryParams).toString(); window.history.pushState({}, '', url.href); - this.handleRouteChange(path, queryParams); + this.#handleRouteChange(path, queryParams); } + /** + * Update the state of the current route (i.e. it's query params). + * + * @param {object} queryParams - query parameters to be associated with the route. + */ updateState(queryParams) { this.navigateTo(window.location.pathname, queryParams); } @@ -117,14 +147,22 @@ class Router extends EventTarget { /** * Handles a change in the route. * + * @private * @param {string} path - The path of the new route. * @param {object} [queryParams={}] - (Optional) The query parameters associated with the route. + * @param {boolean} [popstate=false] - (Optional) if a popstate is at the origin of this route change. */ - handleRouteChange(path, queryParams = {}) { + #handleRouteChange(path, queryParams = {}, popstate = false) { if (this.isActiveRoute(path)) { if (!this.#matchCurrentParams(queryParams)) { this.#currentQueryParams = queryParams; - this.dispatchEvent(new Event('queryparams')); + this.dispatchEvent(new CustomEvent('queryparams', { + detail: { + route: this.#currentRoute, + popstate, + queryParams + } + })); } return; @@ -133,13 +171,20 @@ class Router extends EventTarget { const route = this.findRoute(path); if (route) - this.#updateCurrentRoute(route, queryParams); - else if (this.#fallback) - this.handleRouteChange(this.#fallback); + this.#updateCurrentRoute(route, queryParams, popstate); + else if (this.#defaultPath) + this.#handleRouteChange(this.#defaultPath, queryParams, popstate); else throw Error(`No route found for '${path}'`); } + /** + * Checks if the given query parameters match the current query parameters. + * + * @private + * @param {object} params - The query parameters to compare. + * @returns {boolean} - True if the given parameters match the current query parameters, false otherwise. + */ #matchCurrentParams(params) { const paramsKeys = Object.keys(params); @@ -149,33 +194,44 @@ class Router extends EventTarget { return paramsKeys.every(k => this.#currentQueryParams[k] === params[k]); } - #updateCurrentRoute(route, queryParams) { + /** + * Updates the current route and dispatches the 'routechanged' event. + * + * @private + * @param {object} route - The route object. + * @param {object} queryParams - The query parameters associated with the route. + * @param {boolean} [popstate=false] - (Optional) if a popstate is at the origin of this route change. + */ + #updateCurrentRoute(route, queryParams, popstate = false) { route.destroy(); this.#currentRoute = route; this.#currentQueryParams = queryParams; route.start(queryParams); - this.dispatchEvent(new Event('routechanged')); + this.dispatchEvent(new CustomEvent('routechanged', { + detail: { + route, + popstate, + queryParams + } + })); } /** - * Initializes the base path for the router based on the current window location pathname. - * If the current pathname matches a route, the base is set to the pathname with the last path segment removed. - * If there is no matching route, the base is set to the full pathname. + * Initiates the router based on the current window location. + * + * @param defaultPath - The fallback path when no path is found during route resolving. */ - initBase() { - const pathname = window.location.pathname; + start({ defaultPath }) { + const url = new URL(window.location.href); + const path = url.pathname; + const queryParams = Object.fromEntries(url.searchParams.entries()); - this.base = this.findRoute(pathname) ? - pathname.replace(/\/[^/]+\/?$/, '/') : - pathname; - } + this.#defaultPath = defaultPath; + this.base = this.findRoute(path) ? + path.replace(/\/[^/]+\/?$/, '/') : + path; - /** - * TODO Serves the purpose of having a fallback url in case of none found. We should - * rework this router in order to handle relative paths better. - */ - set fallback(fallback) { - this.#fallback = fallback; + this.#handleRouteChange(path, queryParams); } get queryParams() { diff --git a/demo/src/core/string-utils.js b/demo/src/core/string-utils.js new file mode 100644 index 0000000..d1ee987 --- /dev/null +++ b/demo/src/core/string-utils.js @@ -0,0 +1,16 @@ +/** + * Converts a given string to kebab case, spaces are replaced with hyphens + * and all letters are converted to lowercase. + * + * @param {string} str - The input string to be converted to kebab case. + * @returns {string} - The input string converted to kebab case. + * + * @example + * const result = toKebabCase("Hello World"); + * console.log(result); // Output: "hello-world" + * + * @example + * const result = toKebabCase("CamelCase Example"); + * console.log(result); // Output: "camelcase-example" + */ +export const toKebabCase = (str) => str.replace(/\s+/g, '-').toLowerCase(); diff --git a/demo/src/index.js b/demo/src/index.js index 6da8dfe..79bc3bc 100644 --- a/demo/src/index.js +++ b/demo/src/index.js @@ -11,11 +11,4 @@ import './lists/lists-page'; import router from './core/router'; // Initialize the router with the current path or 'examples' if none is found -router.initBase(); -router.fallback = 'examples'; - -const url = new URL(window.location.href); -const path = url.pathname; -const queryParams = Object.fromEntries(url.searchParams.entries()); - -router.handleRouteChange(path, queryParams); +router.start({ defaultPath: 'examples' }); diff --git a/demo/src/lists/lists-page-state-manager.js b/demo/src/lists/lists-page-state-manager.js new file mode 100644 index 0000000..aa42981 --- /dev/null +++ b/demo/src/lists/lists-page-state-manager.js @@ -0,0 +1,186 @@ +import { toKebabCase } from '../core/string-utils'; + +/** + * Manages the state of a lists page, allowing navigation and retrieval of section data. + * + * @class + */ +class ListsPageStateManager { + /** + * Creates an instance of ListsPageStateManager. + * + * @constructor + * @param {Array
} root - The root level of the lists page. + */ + constructor(root) { + /** + * Stack to keep track of the traversal steps for navigation. + * + * @private + * @type {Array<{level: Array
, sectionIndex: number, nodeIndex: number}>} + */ + this.stack = []; + /** + * The current level of the content tree. + * + * @private + * @type {Array
} + */ + this.level = root; + } + + /** + * Initializes the state manager with the provided section, business unit, and nodes. + * + * @async + * @param {string} section - The section to initialize. + * @param {string} bu - The business unit associated with the section. + * @param {string} nodes - A comma-separated string of nodes representing the initial state. + * @returns {Promise} - A promise that resolves when initialization is complete. + * + * @example + * // Example Usage: + * await stateManager.initialize("radio-shows", "rts", "a9e7621504c6959e35c3ecbe7f6bed0446cdf8da,urn:rts:show:radio:9801398"); + */ + async initialize(section, bu, nodes) { + if (!section || !bu) { + return; + } + + const sectionIndex = this.#findSectionIndex(section); + const nodeIndex = this.#findNodeIndex(this.level[sectionIndex].nodes, bu); + + await this.fetchNextState(sectionIndex, nodeIndex); + + + for (const nodeStr of (nodes?.split(',') || [])) { + const nodeIndex = this.#findNodeIndex(this.level[0].nodes, nodeStr); + + await this.fetchNextState(0, nodeIndex); + } + } + + /** + * Fetches the next state based on the provided section index and node index. + * + * @param {number} sectionIndex - The index of the section. + * @param {number} nodeIndex - The index of the node. + * @returns {Promise} - A promise that resolves when the state is fetched. + */ + async fetchNextState(sectionIndex, nodeIndex) { + const section = this.level[sectionIndex]; + + this.stack.push({ level: this.level, sectionIndex, nodeIndex }); + this.level = [await section.resolve(section.nodes[nodeIndex])]; + } + + /** + * Fetches the previous state based on the provided stack index. + * + * @param {number} stackIndex - The index in the stack. + */ + fetchPreviousState(stackIndex) { + this.level = this.stack[stackIndex].level; + this.stack.splice(stackIndex); + } + + /** + * Checks if the specified section at the given index is a leaf section. + * + * @param {number} sectionIndex - The index of the section. + * + * @returns {boolean} - True if the section is a leaf section, false otherwise. + */ + isLeafSection(sectionIndex) { + return this.level[sectionIndex]?.isLeaf(); + } + + /** + * Retrieves the node at the specified section and node indices. + * + * @param {number} sectionIndex - The index of the section. + * @param {number} nodeIndex - The index of the node. + * + * @returns {any} - The retrieved node. + */ + retrieveNode(sectionIndex, nodeIndex) { + return this.level[sectionIndex]?.nodes[nodeIndex]; + } + + /** + * Gets the root level of the content tree. + * + * @returns {Array
} - The root level of the content tree. + */ + get root() { + return this.stack[0]?.level || this.level; + } + + /** + * Return the current state of this manager as query params that are parsable + * by {@link #initialize}. + * + * @returns {{}|{bu: string, section: string, nodes?: string}} The current state as query params. + */ + get params() { + if (this.stack.length === 0) { + return {}; + } + + const root = this.stack[0]; + const rootSection = root.level[root.sectionIndex].toLowerCase(); + const nodes = this.stack.slice(1).map(n => { + const node = n.level[n.sectionIndex].nodes[n.nodeIndex]; + + return node.id || node.urn; + }); + let params = { + section: toKebabCase(rootSection.title), + bu: rootSection.nodes[root.nodeIndex] + }; + + if (nodes && nodes.length) { + params['nodes'] = nodes.join(','); + } + + return params; + } + + + /** + * Finds the index of a section based on its title in kebab case. + * + * @private + * @param {string} sectionStr - The section title to find. + * @returns {number} - The index of the section. + * + * @example + * const index = stateManager.#findSectionIndex("Products"); + */ + #findSectionIndex(sectionStr) { + const normalizedSectionStr = toKebabCase(sectionStr).toLowerCase(); + + return this.level + .map(s => toKebabCase(s.title).toLowerCase()) + .findIndex(title => title === normalizedSectionStr); + } + + /** + * Finds the index of a node based on its string representation. + * + * @private + * @param {Array} nodes - The array of nodes to search. + * @param {string} str - The string representation of the node to find. + * + * @returns {number} - The index of the node. + */ + #findNodeIndex(nodes, str) { + const normalizedStr = str.toLowerCase(); + + return nodes + .map(n => (n.urn || n.id || n.toString()).toLowerCase()) + .findIndex(n => n === normalizedStr); + } +} + +export default ListsPageStateManager; diff --git a/demo/src/lists/lists-page.js b/demo/src/lists/lists-page.js index 1c14f00..d04cc17 100644 --- a/demo/src/lists/lists-page.js +++ b/demo/src/lists/lists-page.js @@ -12,6 +12,7 @@ import SpinnerComponent from '../core/spinner-component'; import Pillarbox from '../../../src/pillarbox'; import IntersectionObserverComponent from '../core/intersection-observer-component'; +import ListsPageStateManager from './lists-page-state-manager'; /** * Represents the Lists page. @@ -26,13 +27,6 @@ class ListsPage { * @type {SpinnerComponent} */ #spinner; - /** - * Stack to keep track of the traversal steps for navigation. - * - * @private - * @type {Array>} - */ - #traversalStack = []; /** * The DOM element that contains the sections. * @@ -47,13 +41,6 @@ class ListsPage { * @type {Element} */ #treeNavigationEl; - /** - * The current level of the content tree. - * - * @private - * @type {Array
} - */ - #currentLevel; /** * The abort controller for handling search cancellation. * @@ -69,6 +56,8 @@ class ListsPage { */ #intersectionObserverComponent; + #stateManager; + /** * Creates an instance of ListsPage. * @@ -76,7 +65,7 @@ class ListsPage { * @param {Array} contentRoot - The root of the content tree. */ constructor(contentRoot) { - this.#currentLevel = contentRoot; + this.#stateManager = new ListsPageStateManager(contentRoot); } /** @@ -97,7 +86,7 @@ class ListsPage { this.#sectionsEl = document.querySelector('#sections'); this.#treeNavigationEl = document.querySelector('#tree-navigation'); - this.updateView(this.#currentLevel); + this.updateView(); this.initListeners(); } @@ -117,6 +106,7 @@ class ListsPage { try { await this.navigateTo(sectionIndex, nodeIndex); + this.#updateRouterState(); } finally { this.#spinner.hide(); } @@ -125,12 +115,9 @@ class ListsPage { // Attach navigation listener this.#treeNavigationEl.addEventListener('click', (event) => { if (event.target.tagName.toLowerCase() !== 'button') return; - - const navigationIdx = event.target.dataset.navigationIdx; - - this.#currentLevel = this.#traversalStack[navigationIdx].level; - this.#traversalStack.splice(navigationIdx); + this.#stateManager.fetchPreviousState(event.target.dataset.navigationIdx); this.updateView(); + this.#updateRouterState(); }); } @@ -141,25 +128,51 @@ class ListsPage { * @param {number} nodeIndex - The index of the node. */ async navigateTo(sectionIndex, nodeIndex) { - const selectedSection = this.#currentLevel[sectionIndex]; - const selectedNode = selectedSection.nodes[nodeIndex]; + if (this.#stateManager.isLeafSection(sectionIndex)) { + const selectedNode = this.#stateManager + .retrieveNode(sectionIndex, nodeIndex); - if (selectedSection.isLeaf()) { openPlayerModal({ src: selectedNode.urn, type: 'srgssr/urn' }); } else { this.#sectionsEl.replaceChildren(); - const nextLevel = [await selectedSection.resolve(selectedNode)]; - - this.#traversalStack.push({ - level: this.#currentLevel, - sectionIndex, - nodeIndex - }); - this.#currentLevel = nextLevel; + await this.#stateManager.fetchNextState(sectionIndex, nodeIndex); this.updateView(); } } + /** + * Handles the state change event by navigating to the specified section and + * nodes in the content tree based on the provided parameters. + * + * This method updates the ListsPage instance's state, triggering navigation + * and ensuring the view is in sync with the new state. + * + * @param {object} options - Options related to the state change event. + * @param {string} options.section - The section to navigate to. + * @param {string} options.bu - The business unit (node) to navigate to. + * @param {string} options.nodes - A comma-separated list of node identifiers indicating additional nodes to navigate to. + */ + async onStateChanged({ section, bu, nodes }) { + const manager = new ListsPageStateManager( + this.#stateManager.root + ); + + await manager.initialize(section, bu, nodes); + this.#stateManager = manager; + + this.updateView(); + } + + + /** + * Updates the state of the router based on the current traversal stack. + * This method is responsible for updating the router's state with the current navigation information, + * ensuring that the browser's URL reflects the current state of the ListsPage instance. + */ + #updateRouterState() { + router.updateState(this.#stateManager.params); + } + /** * Updates the view of the content tree page. */ @@ -174,7 +187,7 @@ class ListsPage { updateSections() { this.#intersectionObserverComponent?.remove(); this.#sectionsEl.replaceChildren( - ...parseHtml(this.#currentLevel.map((section, idx) => ` + ...parseHtml(this.#stateManager.level.map((section, idx) => `

${section.title}

${this.createNodesHtml(section.nodes)} @@ -203,9 +216,9 @@ class ListsPage { * method when it comes into view, allowing the loading of the next set of nodes. */ initIntersectionObserver() { - const firstSection = this.#currentLevel[0]; + const firstSection = this.#stateManager.level[0]; - if (this.#currentLevel.length !== 1 || !firstSection.next) return; + if (this.#stateManager.level.length !== 1 || !firstSection.next) return; this.#intersectionObserverComponent = new IntersectionObserverComponent( (n) => this.#sectionsEl.insertAdjacentElement('afterend', n), @@ -272,10 +285,10 @@ class ListsPage { * Updates the navigation in the content tree page. */ updateNavigation() { - if (this.#traversalStack.length > 0) { + if (this.#stateManager.stack.length > 0) { this.#treeNavigationEl.replaceChildren(...parseHtml(` - ${this.#traversalStack.slice(1).map((step, idx) => ` + ${this.#stateManager.stack.slice(1).map((step, idx) => ` chevron_right `).join('')} @@ -286,5 +299,24 @@ class ListsPage { } } -// Add route for 'content-tree' path -router.addRoute('lists', () => new ListsPage(listsSections).init()); +let onStateChangedListener; + +// Add route for 'lists' path +router.addRoute('lists', async (queryParams) => { + const listsPage = new ListsPage(listsSections); + + listsPage.init(); + + onStateChangedListener = async (event) => { + if (event.detail.popstate) { + // If the state change is triggered externally we force the update, + // otherwise the page is already aware of the change. + await listsPage.onStateChanged(event.detail.queryParams); + } + }; + + router.addEventListener('queryparams', onStateChangedListener); + await listsPage.onStateChanged(queryParams); +}, () => { + router.removeEventListener('queryparams', onStateChangedListener); +});