29 | /**
30 | * hafcaf is initialized as a module using a plain old JavaScript object (POJO).
31 | * @module hafcaf
32 | */
33 | const hafcaf = {
34 | /**
35 | * @typedef {Object} route
36 | * @prop {string} id - The identifier to be used for this route.
37 | * @prop {string} [linkClass]=null - What classname(s) to add to the 'a' tags used to create menu items.
38 | * @prop {string} [linkHTML]=null - What HTML/text to use when creating a menu item for this route. A menu item will not be created if linkHTML is not provided.
39 | * @prop {string} [linkTagClass] - What css classes to give to the menu item container for this route.
40 | * @prop {string} [pageClass] - What css classes to give to the page for this route.
41 | * @prop {string} [innerHTML] - The content of the page. If not provided, will default to config.loadingHTML. Can be set or overwritten later using hafcaf.updateRoute().
42 | * @prop {function} [onRender] - A function which will be called each time this route is rendered (made active). Can include multiple functions within itself, if desired. When composing your onRender, keep in mind to take advantage of the hafcaf.listeners collection, which can be used to hold removeEventListener calls and other functions you would like to run when hafcaf switches away from this route.
43 | */
44 |
45 | /**
46 | * @type {{ [key: string]: route }}
47 | * @description A collection of all of the routes registered with hafcaf.
48 | */
49 | routes: {},
50 |
51 | /**
52 | * @typedef {Object} config
53 | * @prop {string} activeClass="active" - What classname(s) to apply to the link container for the current route.
54 | * @prop {string | undefined} [linkClass] at classname(s) to add to the 'a' tags used to create menu items.
55 | * @prop {string} linkTag="li" - What tag to use for the link container for a route's menu item.
56 | * @prop {string | undefined} [linkTagClass] - What classname(s) to give to the menu item container for this route.
57 | * @prop {string} loadingHTML - The default HTML to display when a route hasn't yet been updated with its real content. Useful when loading routes dynamically using AJAX or Fetch.
58 | * @prop {string} mainID="main-container" - The id attribute of the container to which pages should be added.
59 | * @prop {string} navID="nav-list" - The id attribute of the container to which menu items should be added.
60 | * @prop {string | undefined} [pageClass] - What css classnames to give to route pages.
61 | * @prop {string} pageTag="div" - What tag to use when creating a page's container.
62 | */
63 |
64 | /** @type {config} */
65 | config: {
66 | activeClass: "active",
67 | linkClass: undefined,
68 | linkTag: "li",
69 | linkTagClass: undefined,
70 | loadingHTML: "<p>Loading...</p>",
71 | mainID: "main-container",
72 | navID: "nav-list",
73 | pageClass: undefined,
74 | pageTag: "div"
75 | },
76 |
77 | /**
78 | * @property {array} exitFunctions - Holds a collection of functions to be called when the current route changes, as a convenience for onRender functions that add event listeners and the like. Especially useful for cancelling subscriptions to streams or long-polling operations. Will get automatically called at the beginning of every routeChange() call.
79 | * @type {Function[]}
80 | */
81 | exitFunctions: [],
82 |
83 | /**
84 | * addRoute() is the method to use when you wish to add a route for hafcaf to keep track of. It takes a configuration object, all properties of which are optional except for `id`.
85 | * @param {route} newRoute
86 | */
87 | addRoute(newRoute) {
88 | const id = newRoute.id;
89 |
90 | // Check if a route already exists with the given ID
91 | if (this.routes[id] !== undefined) {
92 | console.error(
93 | `A route with the ID ${id} already exists. Please use the updateRoute() method if you wish to update it, or change this route's ID if you still want to add it.`
94 | );
95 | return;
96 | }
97 |
98 | // Add the route to the collection of routes
99 | this.routes[id] = newRoute;
100 |
101 | // Add the route to the navigation menu if linkHTML provided
102 | if (newRoute.linkHTML) {
103 | const newEl = document.createElement(this.config.linkTag);
104 |
105 | const linkTagClass = newRoute.linkTagClass || this.config.linkTagClass;
106 |
107 | if (linkTagClass) {
108 | newEl.classList.add(linkTagClass);
109 | }
110 |
111 | const newLink = document.createElement("a");
112 | newLink.href = `#${id}`;
113 | newLink.innerHTML = newRoute.linkHTML;
114 |
115 | // Add classes to the link, if present
116 | const linkClass = newRoute.linkClass || this.config.linkClass;
117 |
118 | if (linkClass) {
119 | newLink.classList.add(linkClass);
120 | }
121 |
122 | newEl.appendChild(newLink);
123 | document.getElementById(this.config.navID)?.appendChild(newEl);
124 | }
125 |
126 | // Check if the ID already exists in the DOM (i.e. adding an existing page to the dom)
127 | const doesNotExist = document.getElementById(id) === null;
128 |
129 | if (doesNotExist) {
130 | // Create a new page
131 | const newEl = document.createElement(this.config.pageTag);
132 | newEl.id = id;
133 |
134 | // Add classes to the page, if present
135 | const pageClass = newRoute.pageClass || this.config.pageClass;
136 |
137 | if (pageClass) {
138 | newEl.classList.add(pageClass);
139 | }
140 |
141 | // If this new route provides html, add it to the DOM, else use the loadingHTML
142 | newEl.innerHTML = newRoute.innerHTML || this.config.loadingHTML;
143 |
144 | // Add page to the DOM
145 | document.getElementById(this.config.mainID)?.appendChild(newEl);
146 | }
147 |
148 | // Get the new hash, which is the route to be rendered
149 | const currentRouteID = location.hash.slice(1);
150 |
151 | if (id === currentRouteID) this.routeChange();
152 | },
153 |
154 | /**
155 | * @property {string} - The id attribute of the route to redirect to if hafcaf is asked to redirect to a route that doesn't exist. When creating your initial html, this should be the last page in the list of pages in your page container.
156 | */
157 | defaultRouteID: "home",
158 |
159 | /**
160 | * updateRoute() is used - naturally - to update a route's content. In addition to the page's content, one can also update the route's link's innerHTML and the route's onRender function. updateRoute() calls routeChange() at the end if the user is currently viewing the route that was just updated.
161 | * @param {route} routeToUpdate
162 | */
163 | updateRoute(routeToUpdate) {
164 | const id = routeToUpdate.id;
165 | const route = this.routes[id];
166 |
167 | if (!route) {
168 | console.error(`A route with the ID ${id} does not exist, cannot update it.`);
169 | return false;
170 | }
171 |
172 | if (routeToUpdate.linkHTML) {
173 | // First, find the link's 'a' tag by looking up the link's href
174 | const linkEl = document.querySelector(`a[href='#${id}']`);
175 |
176 | // Then, update the link's innerHTML with the new content
177 | if (linkEl) linkEl.innerHTML = routeToUpdate.linkHTML;
178 | }
179 |
180 | if (routeToUpdate.innerHTML) {
181 | // First, find the page via its id
182 | const pageEl = document.getElementById(id);
183 |
184 | // Then, update the page's innerHTML with the new content
185 | if (pageEl) pageEl.innerHTML = routeToUpdate.innerHTML;
186 | }
187 |
188 | if (routeToUpdate.onRender) route.onRender = routeToUpdate.onRender;
189 |
190 | // Get the new hash, which is the route to be rendered
191 | const currentRouteID = location.hash.slice(1);
192 |
193 | if (id === currentRouteID) this.routeChange();
194 | },
195 |
196 | /**
197 | * routeChange() is a function called by hafcaf everytime a route is changed. You likely will not ever need to call it directly. The first thing it does is check to make sure the route desired is being tracked by hafcaf already. If it is, then the next step is to remove the `activeClass` from any existing elements that might have it. Third, if there are any functions in {@link hafcaf.exitFunctions}, then call those. Fourthly, find the menu item for the new active route and make it active. Finally, if the new route has an `onRender` function registered, call it.
198 | */
199 | routeChange() {
200 | // Get the new hash, which is the route to be rendered
201 | const routeID = location.hash.slice(1);
202 |
203 | // From the routes known to hafcaf, pick out the matching one
204 | // If the desired route is not found, redirect to default page
205 | let route = this.routes[routeID] || this.routes[this.defaultRouteID];
206 |
207 | // If the default route doesn't exist yet either, return early
208 | if (!route) return;
209 |
210 | // Remove any existing active classes upon route changing
211 | const { activeClass } = this.config;
212 | for (const el of document.getElementsByClassName(activeClass)) {
213 | el.classList.remove(activeClass);
214 | }
215 |
216 | // Iterate through the exitFunctions collection and call any functions found there
217 | while (this.exitFunctions.length > 0) {
218 | // Dispose all of the registered exit functions
219 | this.exitFunctions.pop()?.();
220 | }
221 |
222 | // Next, find the new route's 'a' tag by looking up the link's href
223 | const linkEl = document.querySelector(`a[href='#${route.id}']`);
224 |
225 | // Make it active
226 | if (linkEl) linkEl.classList.add(activeClass);
227 |
228 | // If the route has an "onRender" callback, call it
229 | if (route.onRender !== undefined) route.onRender();
230 |
231 | // Last but not least, make sure the hash location gets updated in case of redirection
232 | if (window.location.hash.slice(1) !== route.id) {
233 | window.location.hash = route.id;
234 | }
235 | },
236 |
237 | /**
238 | * The init() function assigns its config object as the config (defaults) for hafcaf. Though it's recommended to only change the individual values needed, this option is provided in case you wish to change several or all values at once.
239 | *
240 | * init() additionally sets up a "hashchange" event listener on the window object, so that the routeChange() function will be called when the route changes. Finally, init() will set the hash to the defaultRouteID if it has not already been set (for instance, when following a link to a hafcaf site or refreshing a page) and will then call hafcaf.routeChange() to make sure the pertinent routines are executed.
241 | * @param {config} config
242 | */
243 | init(config) {
244 | if (config) this.config = {...this.config, ...config};
245 |
246 | // Add a global listener for 'hashchange', since this framework relies on hash-based routing
247 | window.addEventListener("hashchange", () => {
248 | this.routeChange();
249 | });
250 |
251 | // Set hash to default if no hash
252 | if (!window.location.hash) {
253 | window.location.hash = this.defaultRouteID;
254 | }
255 |
256 | this.routeChange();
257 | }
258 | };
259 |
260 | export default hafcaf;
261 |
262 |
263 |