@peckadesign/pd-naja 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.editorconfig ADDED
@@ -0,0 +1,14 @@
1
+ # editorconfig.org
2
+ root = true
3
+
4
+ [*]
5
+ indent_style = tab
6
+ tab_width = unset
7
+ indent_size = tab
8
+ end_of_line = lf
9
+ charset = utf-8
10
+ trim_trailing_whitespace = true
11
+ insert_final_newline = true
12
+
13
+ [*.md]
14
+ trim_trailing_whitespace = false
package/.eslintrc.js ADDED
@@ -0,0 +1,12 @@
1
+ require('@rushstack/eslint-patch/modern-module-resolution')
2
+
3
+ module.exports = {
4
+ root: true,
5
+ extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'plugin:prettier/recommended', 'prettier'],
6
+ rules: {
7
+ '@typescript-eslint/ban-ts-comment': 'off',
8
+ '@typescript-eslint/no-explicit-any': 'off',
9
+ 'prettier/prettier': 1
10
+ },
11
+ ignorePatterns: ['*.js']
12
+ }
@@ -0,0 +1,29 @@
1
+ name: CI
2
+ on:
3
+ pull_request:
4
+ types: [opened, synchronize]
5
+
6
+ jobs:
7
+ lint:
8
+ runs-on: ubuntu-latest
9
+ name: ESLint
10
+ env:
11
+ CI: true
12
+ steps:
13
+ - name: Checkout 🛎
14
+ uses: actions/checkout@v3
15
+
16
+ - name: Setup Node 📦
17
+ uses: actions/setup-node@v3
18
+ with:
19
+ node-version: lts/*
20
+ cache: npm
21
+
22
+ - name: Install dependencies 👨🏻‍💻
23
+ run: npm ci
24
+
25
+ - name: Lint ✨
26
+ run: npm run lint
27
+
28
+ - name: Build 🔨
29
+ run: npm run build
@@ -0,0 +1,80 @@
1
+ name: Publish
2
+ on:
3
+ push:
4
+ tags:
5
+ - '*'
6
+
7
+ jobs:
8
+ build:
9
+ name: Build
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - name: Checkout 🛎
13
+ uses: actions/checkout@v3
14
+
15
+ - name: Setup Node 📦
16
+ uses: actions/setup-node@v3
17
+ with:
18
+ node-version: lts/*
19
+ cache: npm
20
+
21
+ - name: Install dependencies 👨🏻‍💻
22
+ run: npm ci
23
+
24
+ - name: Build 🔨
25
+ run: npm run build
26
+
27
+ - name: Download artifacts 🧩
28
+ uses: actions/upload-artifact@v3
29
+ with:
30
+ name: dist-files
31
+ path: dist/
32
+
33
+ release:
34
+ name: Release
35
+ runs-on: ubuntu-latest
36
+ needs: [build]
37
+ steps:
38
+ - name: Checkout 🛎
39
+ uses: actions/checkout@v3
40
+
41
+ - name: Download artifacts 🧩
42
+ uses: actions/download-artifact@v3
43
+ with:
44
+ name: dist-files
45
+ path: dist/
46
+
47
+ - name: Create release draft 🕊️
48
+ uses: softprops/action-gh-release@v1
49
+ with:
50
+ draft: true
51
+ files: |
52
+ dist/PdModal.js
53
+ dist/PdModal.js.map
54
+ dist/PdModal.min.js
55
+ dist/PdModal.min.js.map
56
+
57
+ publish:
58
+ name: Publish
59
+ runs-on: ubuntu-latest
60
+ needs: [build]
61
+ steps:
62
+ - name: Checkout 🛎
63
+ uses: actions/checkout@v3
64
+
65
+ - name: Download artifacts 🧩
66
+ uses: actions/download-artifact@v3
67
+ with:
68
+ name: dist-files
69
+ path: dist/
70
+
71
+ - name: Setup Node 📦
72
+ uses: actions/setup-node@v3
73
+ with:
74
+ node-version: lts/*
75
+ registry-url: 'https://registry.npmjs.org'
76
+
77
+ - name: Publish release 🕊️
78
+ run: npm publish --access public
79
+ env:
80
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }}
package/.prettierrc ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "singleQuote": true,
3
+ "semi": false,
4
+ "trailingComma": "none",
5
+ "printWidth": 120
6
+ }
package/README.md ADDED
@@ -0,0 +1,90 @@
1
+ # pd-naja
2
+
3
+ 1. [Quick start](#quick-start)
4
+ 2. [Utilities](#utilities)
5
+ 3. [Extensions](#extensions)
6
+ 1. [AjaxModalExtension](#ajaxmodalextension)
7
+ 2. [SpinnerExtension](#spinnerextension)
8
+
9
+ ## Quick start
10
+ ```
11
+ $ npm install naja @peckadesign/pd-naja
12
+ ```
13
+
14
+ ```typescript
15
+ import naja from 'naja'
16
+ import { ExtensionName } from '@peckadesign/pd-naja'
17
+
18
+ naja.registerExtension(new ExtensionName())
19
+ ```
20
+
21
+ ```typescript
22
+ import { controlManager } from '@peckadesign/pd-naja'
23
+ import SomeControl from '@/js/Controls/SomeControl' // `SomeControl` must implement `Control` interface
24
+ import SomeAnotherControl from '@/js/Controls/SomeControl'
25
+
26
+ // This control is only initialized on page load
27
+ controlManager.addControlOnLoad(SomeControl)
28
+
29
+ // This control will also get initialized after Naja requests
30
+ controlManager.addControlOnLive(SomeControl)
31
+
32
+ ```
33
+
34
+ ## Utilities
35
+ This package provides easy ways to add JS components reactive to Naja ajax events.
36
+
37
+ `Control` is meant to be used for standalone components, that might be dependent on ajax. It should be used together with `ControlManager`. Class implementing the `Control` interface **should export its instance**. Then the intended lifecycle of class is as follows:
38
+
39
+ 1. `constructor` is called immediately and is also called only once. This is the ideal place for e.g. adding common event handlers to the `body`, or modifying necessary DOM properties on elements not affected by ajax.
40
+
41
+ 2. The instantiated class is then added to ControlManager either by `addControlOnLoad` or `addControlOnLive`. This ensures, that on `DOMContentLoaded` the `initialize` function of class is called. This method should implement initialization of the control dependent on fully loaded DOM. The `context` argument is equal to `document` in this call.
42
+
43
+ 3. In case of `addControlOnLive` used, the `initialize` method is also called for each success ajax request. The `context` argument is equal to modified nette snippet.
44
+
45
+
46
+ ## Extensions
47
+
48
+ ### `AjaxModalExtension`
49
+ This extension allows you to implement modal window with browser history using Naja. Extension itself is agnostic of used modal, you can use any modal plugin you want as long as you provide simple adapter implementing `AjaxModal` interface. Class implementing `AjaxModal` is only parameter recieved by constructor.
50
+
51
+ ```typescript
52
+ import naja from 'naja'
53
+ import { AjaxModalExtension } from '@peckadesign/pd-naja'
54
+ import { modal } from '@/js/App/PdModal' // modal must implement AjaxModal interface
55
+
56
+ naja.registerExtension(
57
+ new AjaxModalExtension(modal)
58
+ )
59
+ ```
60
+
61
+ #### Interface `AjaxModal`
62
+ | Property | Description |
63
+ |-----------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
64
+ | `element: Element` | Most outer element of the modal window. |
65
+ | `reservedSnippetIds: string[]` | List of snippet id's, that are neccessary for modal to work, e.g. `snippet--modal`. |
66
+ | `show(opener: Element \| undefined, options: any, event: BeforeEvent \| PopStateEvent): void` | Method that is called for opening the modal. `opener` is the element causing the opening, event is the associated event. |
67
+ | `hide(event: SuccessEvent \| PopStateEvent): void` | Method that is called for closing the modal either as a result of `closeModal` property in ajax response or as a result of navigating using browser history. |
68
+ | `isShown(): boolean` | Should return wheter the modal is opened or not. |
69
+ | `onShow: (callback: EventListener) => void`<br/>`onHide: (callback: EventListener) => void`<br/>`onHidden: (callback: EventListener) => void` | The extension needs to attach some handlers when show, hide or hidden happens. These functions should provide a way to add listeners to such events. |
70
+ | `dispatchLoad?: (options: any, event: SuccessEvent \| PopStateEvent) => void` | If you provide this method, extension will call it whenever the content is changed (loaded). |
71
+ | `getOptions(opener: Element): any` | If you need to change some modal settings based on opener element, you can do that in this function. Return value of this function is stored in `localStorage` (or wherever is Naja configured to store states) and also in `HistoryState`. Bear in mind that this introduces some limitations of what can be returned (e.g. no `Element` is allowed). |
72
+ | `setOptions(options: any): void` | Counterpart of `getOptions` method, you can use this method to restore modal settings based on `options`. |
73
+
74
+ ### `SpinnerExtension`
75
+
76
+ This extension allows you to add configurable loading indicator to ajax request. Constructor recieves following 4 parameters:
77
+
78
+ | Parameter | Description |
79
+ |--------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
80
+ | `spinner: ((props?: any) => Element) \| Element` | Mandatory parameter. It should either be function return the spinner element, or directly element. |
81
+ | `getSpinnerProps: ((initator: Element) => any) \| undefined = undefined` | If you provide `spinner` as a function, you might also provide function to get settings from ajax initiator. Returned value is passed as a `props` to `spinner()` call. |
82
+ | `ajaxSpinnerWrapSelector = '.ajax-wrap'` | See below. |
83
+ |`ajaxSpinnerPlaceholderSelector = '.ajax-spinner'`| See below |
84
+
85
+ The logic for spinner placeholder is as follows:
86
+ 1. Extension can be turned off by using `data-naja-spinner="off"`.
87
+ 2. If there is `data-naja-spinner` with different value, this value is used as a selector for element into which the spinner element is appended.
88
+ 3. If there is no `data-naja-spinner`, closest `ajaxSpinnerWrapSelector` is being searched for and:
89
+ 1. If there is `ajaxSpinnerPlaceholderSelector` inside, this element is used for placing spinner element.
90
+ 2. If not, the spinner element is appended into `ajaxSpinnerWrapSelector` itself.
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@peckadesign/pd-naja",
3
+ "title": "PD Naja utils & extensions",
4
+ "version": "1.0.0",
5
+ "private": false,
6
+ "description": "Utilities and extensions created for use with Naja library.",
7
+ "module": "dist/PdNaja.esm.js",
8
+ "types": "dist/index.esm.d.ts",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/peckadesign/pd-naja.git"
12
+ },
13
+ "keywords": [
14
+ "naja",
15
+ "extension",
16
+ "ajax",
17
+ "typescript",
18
+ "vanilla",
19
+ "javascript"
20
+ ],
21
+ "author": {
22
+ "name": "PeckaDesign, s.r.o",
23
+ "email": "support@peckadesign.cz"
24
+ },
25
+ "license": "MIT",
26
+ "bugs": {
27
+ "url": "https://github.com/peckadesign/pd-naja/issues"
28
+ },
29
+ "homepage": "https://github.com/peckadesign/pd-naja#readme",
30
+ "dependencies": {
31
+ "naja": "^2.5.0"
32
+ },
33
+ "scripts": {
34
+ "build": "NODE_ENV=production rollup -c --bundleConfigAsCjs",
35
+ "lint": "NODE_ENV=development eslint src --ext .ts --max-warnings=0"
36
+ },
37
+ "devDependencies": {
38
+ "@rollup/plugin-babel": "^6.0.3",
39
+ "@rollup/plugin-commonjs": "^24.1.0",
40
+ "@rollup/plugin-node-resolve": "^15.0.2",
41
+ "@rollup/plugin-typescript": "^11.1.0",
42
+ "@rushstack/eslint-patch": "^1.2.0",
43
+ "eslint": "^8.40.0",
44
+ "eslint-config-prettier": "^8.8.0",
45
+ "eslint-config-typescript": "^3.0.0",
46
+ "eslint-plugin-prettier": "^4.2.1",
47
+ "prettier": "^2.8.8",
48
+ "rollup": "^3.21.6",
49
+ "typescript": "^5.0.4"
50
+ }
51
+ }
@@ -0,0 +1,61 @@
1
+ import babel from '@rollup/plugin-babel';
2
+ import commonjs from '@rollup/plugin-commonjs';
3
+ import resolve from '@rollup/plugin-node-resolve';
4
+ import typescript from '@rollup/plugin-typescript';
5
+ import path from 'path';
6
+
7
+ import pkg from './package.json';
8
+ const output = {
9
+ banner: `/**\n * ${pkg.title} - ${pkg.description} \n * ${pkg.homepage}\n *\n * @author ${pkg.author.name} <${pkg.author.email}>\n * @license ${pkg.license}\n *\n * @version ${pkg.version}\n */\n`,
10
+ sourcemap: true,
11
+ };
12
+
13
+ const babelPlugin = babel({
14
+ exclude: /node_modules/,
15
+ include: 'src/**',
16
+ babelHelpers: 'runtime',
17
+ });
18
+
19
+ export default [
20
+ {
21
+ // ESM build for modern tools like webpack
22
+ input: 'src/index.esm.ts',
23
+ output: {
24
+ ...output,
25
+ file: pkg.module,
26
+ format: 'esm',
27
+ },
28
+ external: [
29
+ /@babel\/runtime/,
30
+ ...Object.keys(pkg.dependencies || {}),
31
+ ...Object.keys(pkg.peerDependencies || {}),
32
+ ],
33
+ plugins: [
34
+ resolve(),
35
+ commonjs(),
36
+ typescript(),
37
+ babelPlugin,
38
+ ],
39
+ },
40
+ {
41
+ // type declaration files for ESM build
42
+ input: 'src/index.esm.ts',
43
+ output: {
44
+ ...output,
45
+ dir: path.dirname(pkg.module),
46
+ format: 'esm',
47
+ },
48
+ external: [
49
+ /@babel\/runtime/,
50
+ ...Object.keys(pkg.dependencies || {}),
51
+ ...Object.keys(pkg.peerDependencies || {}),
52
+ ],
53
+ plugins: [
54
+ typescript({
55
+ declaration: true,
56
+ declarationDir: path.dirname(pkg.module),
57
+ emitDeclarationOnly: true,
58
+ }),
59
+ ],
60
+ },
61
+ ];
@@ -0,0 +1,375 @@
1
+ import { Naja, BeforeEvent, CompleteEvent, StartEvent, SuccessEvent, Options, Extension } from 'naja/dist/Naja'
2
+ import { BuildStateEvent, HistoryState } from 'naja/dist/core/HistoryHandler'
3
+ import { InteractionEvent } from 'naja/dist/core/UIHandler'
4
+ import { FetchEvent } from 'naja/dist/core/SnippetCache'
5
+
6
+ declare module 'naja/dist/Naja' {
7
+ interface Options {
8
+ pdModal?: boolean
9
+ modalOpener?: Element
10
+ modalOptions?: any
11
+ }
12
+
13
+ interface Payload {
14
+ closeModal?: boolean
15
+ }
16
+ }
17
+
18
+ type CallbackFn = (callback: EventListener) => void
19
+
20
+ export interface AjaxModal {
21
+ // main element of the modal
22
+ element: Element
23
+
24
+ // id's of snippets, that are necessary for modal function
25
+ reservedSnippetIds: string[]
26
+
27
+ show(opener: Element | undefined, options: any, event: BeforeEvent | PopStateEvent): void
28
+ hide(event: SuccessEvent | PopStateEvent): void
29
+ isShown(): boolean
30
+
31
+ onShow: CallbackFn
32
+ onHide: CallbackFn
33
+ onHidden: CallbackFn
34
+
35
+ dispatchLoad?: (options: any, event: SuccessEvent | PopStateEvent) => void
36
+
37
+ getOptions(opener: Element): any
38
+ setOptions(options: any): void
39
+ }
40
+
41
+ interface HistoryStateWrapper extends Record<string, any> {
42
+ location: string
43
+ state: HistoryState
44
+ title: string
45
+ }
46
+
47
+ type HistoryDirection = 'forwards' | 'backwards'
48
+
49
+ export class AjaxModalExtension implements Extension {
50
+ private readonly modal: AjaxModal
51
+ private readonly uniqueExtKey: string = 'modal'
52
+
53
+ private popstateFlag = false
54
+ private hidePopstateFlag = false
55
+
56
+ private historyEnabled = false // (dis)allows `pushState` after hiding the modal when going back in history (popstate), we don't want to push new state into history same is true also when the history is disabled for request altogether
57
+ private historyDirection: HistoryDirection = 'backwards'
58
+
59
+ private modalOptions: any = {}
60
+
61
+ private original: HistoryStateWrapper[] = [] // stack of states under the modal after hiding the modal with `forwards` history mode, we need to push the previous state
62
+ private lastState: HistoryStateWrapper | null = null
63
+ private initialState: HistoryState | Record<string, never>
64
+
65
+ private readonly abortControllers: Map<string, AbortController> = new Map()
66
+
67
+ public constructor(modal: AjaxModal) {
68
+ // Extension popstate has to be executed before naja popstate, so we can correctly detect if the pdModal is
69
+ // opened. Therefore, we bind the callback before the extension initialization itself.
70
+ window.addEventListener('popstate', this.popstateHandler.bind(this))
71
+
72
+ this.modal = modal
73
+ this.initialState = history.state || {}
74
+ }
75
+
76
+ public initialize(naja: Naja): void {
77
+ naja.uiHandler.addEventListener('interaction', this.checkExtensionEnabled.bind(this))
78
+
79
+ naja.historyHandler.addEventListener('buildState', this.buildState.bind(this))
80
+
81
+ naja.snippetCache.addEventListener('fetch', this.onSnippetFetch.bind(this))
82
+
83
+ naja.addEventListener('before', this.before.bind(this))
84
+ naja.addEventListener('start', this.abortPreviousRequest.bind(this))
85
+ naja.addEventListener('success', this.success.bind(this))
86
+ naja.addEventListener('complete', this.clearRequest.bind(this))
87
+
88
+ this.modal.onShow(this.showHandler.bind(this))
89
+ this.modal.onHide(() => {
90
+ this.removeModalSnippetsIds()
91
+ this.abortControllers.get('modal')?.abort()
92
+ })
93
+ this.modal.onHidden(this.hiddenHandler.bind(this))
94
+ }
95
+
96
+ private isRequestWithHistory = (options: Options): boolean => {
97
+ return options.history !== false
98
+ }
99
+
100
+ private isPdModalState = (state: HistoryState | Record<string, never>): boolean => {
101
+ return 'pdModal' in state && state.pdModal?.isShown
102
+ }
103
+
104
+ private restoreExtensionPropertiesFromState = (state: HistoryState): void => {
105
+ this.historyEnabled = true // Called from popstateHandler means the history is enabled
106
+ this.historyDirection = state.pdModal.historyDirection
107
+ this.modalOptions = state.pdModal.options
108
+ }
109
+
110
+ private checkExtensionEnabled(event: InteractionEvent): void {
111
+ const { element, options } = event.detail
112
+
113
+ options.pdModal =
114
+ this.modal.isShown() ||
115
+ element.hasAttribute('data-naja-modal') ||
116
+ (element as HTMLInputElement).form?.hasAttribute('data-naja-modal')
117
+
118
+ // If the extension is enabled and modal is not opened, we detect and store history mode. History mode cannot
119
+ // change when traversing ajax link inside modal.
120
+ if (!options.pdModal) {
121
+ return
122
+ }
123
+
124
+ // `modalOptions` will be stored in state, therefore no `Element` is allowed. These options are also forwarded
125
+ // to other Naja event handlers via `options`. We also store the element separately to be used there as well.
126
+ this.modalOptions = this.modal.getOptions(element)
127
+
128
+ options.modalOptions = this.modalOptions
129
+ options.modalOpener = element
130
+
131
+ // History direction can only be set before opening the modal, then it stays the same until modal is hidden.
132
+ if (!this.modal.isShown()) {
133
+ this.historyDirection = element.getAttribute('data-naja-modal-history') === 'forwards' ? 'forwards' : 'backwards'
134
+ }
135
+ }
136
+
137
+ private onSnippetFetch(event: FetchEvent): void {
138
+ event.detail.options.pdModal = 'pdModal' in event.detail.state
139
+ }
140
+
141
+ private removeModalSnippetsIds(): void {
142
+ // When closing the modal, we don't want to update any snippets inside it, when other requests finishes. This
143
+ // will ensure, that snippets that might be also outside modal (e.g. flash messages) will be redrawn outside the
144
+ // modal. Therefore, we remove the id attributes, so no snippet is found.
145
+ //
146
+ // Some snippets are necessary for modal function
147
+ this.modal.element.querySelectorAll('[id^="snippet-"]').forEach((snippet) => {
148
+ if (!this.modal.reservedSnippetIds.includes(snippet.id)) {
149
+ snippet.removeAttribute('id')
150
+ }
151
+ })
152
+ }
153
+
154
+ private abortPreviousRequest(event: StartEvent): void {
155
+ const { abortController, options } = event.detail
156
+ if (options.pdModal) {
157
+ this.abortControllers.get(this.uniqueExtKey)?.abort()
158
+ this.abortControllers.set(this.uniqueExtKey, abortController)
159
+ }
160
+ }
161
+
162
+ private clearRequest(event: CompleteEvent): void {
163
+ const { request } = event.detail
164
+ if (!request.signal.aborted) {
165
+ this.abortControllers.delete(this.uniqueExtKey)
166
+ }
167
+ }
168
+
169
+ private buildState(event: BuildStateEvent): void {
170
+ const { options, state } = event.detail
171
+
172
+ // Every time naja builds the state, we extend it with `pdModal` object containing information about modal being
173
+ // opened and what history mode is in use. When options.forceRedirect is set, modal might be open but the new
174
+ // state will be redirected outside it.
175
+ const isShown: boolean = this.modal.isShown() && !options.forceRedirect
176
+ state.pdModal = {
177
+ isShown,
178
+ historyDirection: isShown ? this.historyDirection : null,
179
+ options: isShown ? this.modalOptions : null
180
+ }
181
+
182
+ // If the state is build, the history is enabled. This information is needed inside modal callback where the
183
+ // options are not available, so we store this internally in extension.
184
+ this.historyEnabled = true
185
+ }
186
+
187
+ private before(event: BeforeEvent) {
188
+ const { options, request } = event.detail
189
+ if (!options.pdModal) {
190
+ return
191
+ }
192
+
193
+ this.modal.show(options.modalOpener, options.modalOptions, event)
194
+
195
+ request.headers.append('Pd-Modal-Opened', String(Number(this.modal.isShown())))
196
+ }
197
+
198
+ private success(event: SuccessEvent) {
199
+ const { options, payload } = event.detail
200
+
201
+ this.popstateFlag = false
202
+ this.lastState = {
203
+ location: location.href,
204
+ state: history.state,
205
+ title: document.title
206
+ }
207
+
208
+ if (!options.pdModal) {
209
+ return
210
+ }
211
+
212
+ const requestHistory = this.isRequestWithHistory(options)
213
+
214
+ // If the history is disabled for current request, we will disable it for all ajax links / forms in modal as well.
215
+ if (!requestHistory && event.target) {
216
+ const ajaxified = this.modal.element.querySelectorAll<HTMLElement>((event.target as Naja).uiHandler?.selector)
217
+
218
+ ajaxified.forEach((element: HTMLElement) => {
219
+ element.setAttribute('data-naja-history', 'off')
220
+ })
221
+ }
222
+
223
+ if (payload.closeModal) {
224
+ this.modal.hide(event)
225
+ } else {
226
+ this.modal.setOptions(this.modalOptions)
227
+
228
+ if (this.modal.dispatchLoad) {
229
+ this.modal.dispatchLoad(this.modalOptions, event)
230
+ }
231
+ }
232
+ }
233
+
234
+ private showHandler() {
235
+ this.modal.setOptions(this.modalOptions)
236
+
237
+ // If the modal history mode is `forwards`, we store the state under the modal, so we can push it as a new state
238
+ // after hiding the modal.
239
+ if (this.historyDirection === 'forwards') {
240
+ if (this.popstateFlag && this.lastState) {
241
+ this.original.push(this.lastState)
242
+ } else {
243
+ const state: HistoryStateWrapper = {
244
+ location: location.href,
245
+ state: history.state,
246
+ title: document.title
247
+ }
248
+ this.original.push(state)
249
+ }
250
+ }
251
+ }
252
+
253
+ private hiddenHandler() {
254
+ // This method is called after modal has been hidden. It either pushes a new state into history (mode `forwards`)
255
+ // or calls `history.back()` to start go-back procedure.
256
+ //
257
+ // New state is pushed only if we are able to retrieve the state under the modal (which should have been stored
258
+ // previously) and the modal is not being closed using forward / back buttons in browser.
259
+ if (!this.historyEnabled) {
260
+ return
261
+ }
262
+
263
+ if (this.historyDirection === 'backwards') {
264
+ // We don't know how many states we need to return. We go one by one, see popstate handler. This go-back
265
+ // procedure is detected using `hidePopstateFlag`.
266
+ this.hidePopstateFlag = true
267
+ this.cleanData()
268
+ window.history.back()
269
+ } else if (this.historyDirection === 'forwards') {
270
+ const state = this.original.pop()
271
+ this.original = []
272
+
273
+ if (state) {
274
+ // When closing the modal using forward / back buttons in browser, the current state is the same as the
275
+ // one stored in `this.original`. If that's the case, we don't push anything as it would duplicate the
276
+ // state in history.
277
+ if (history.state === undefined || history.state.href !== state.location) {
278
+ history.pushState(state.state, state.title, state.location)
279
+ document.title = state.title
280
+
281
+ this.popstateFlag = false
282
+ this.lastState = {
283
+ location: state.location,
284
+ state: state.state,
285
+ title: state.title
286
+ }
287
+ }
288
+ }
289
+
290
+ this.cleanData()
291
+ }
292
+ }
293
+
294
+ private popstateHandler(event: PopStateEvent): void {
295
+ const state: HistoryState = event.state || this.initialState
296
+
297
+ if (typeof state === 'undefined' || !this.modal) {
298
+ return
299
+ }
300
+
301
+ const isCurrentStatePdModal = this.isPdModalState(state)
302
+ this.popstateFlag = true
303
+
304
+ // We don't know how many states we go back. So we go one by one until the new state is not modal state
305
+ // (`isPdModalState` is `false`).
306
+ if (this.hidePopstateFlag) {
307
+ // We don't want the naja popstate callback to be executed (or any other popstate handler).
308
+ event.stopImmediatePropagation()
309
+
310
+ if (isCurrentStatePdModal) {
311
+ window.history.back()
312
+
313
+ return
314
+ } else {
315
+ // Todo check if this is really necessary. When used with nette.ajax / history.nette.ajax, this was necessary in some cases, where the title hasn't been restored correctly.
316
+ if (state.title) {
317
+ document.title = state.title
318
+ }
319
+ }
320
+
321
+ this.hidePopstateFlag = false
322
+ }
323
+
324
+ // We check if the state has pdModal object present on popstate. If so (and the pdModal.isShown is true), we proceed
325
+ // to open the modal. Content of the modal is restored by naja itself (either from cache or by new request).
326
+ //
327
+ // If the initial state is also detected as pdModal state, we returned to some pdModal state using reload. In that
328
+ // case, we don't want to open the modal, because we might be missing some snippets. Effectively this means that the
329
+ // modal will never be opened by forward / back button if there has been some other site loaded outside the modal
330
+ // (e.g. some non-ajax link leading from modal).
331
+ if (isCurrentStatePdModal && !this.isPdModalState(this.initialState)) {
332
+ this.restoreExtensionPropertiesFromState(state)
333
+
334
+ this.modal.show(undefined, state.pdModal.options, event)
335
+
336
+ // If there is some snippet cache, we might restore modal options. If not, options will be restored based on
337
+ // options after the ajax request. Same applies to dispatching load event - if cache is on, we dispatch the
338
+ // event immediately, otherwise it will be dispatched after ajax request.
339
+ if (state.snippets?.storage !== 'off') {
340
+ this.modal.setOptions(state.pdModal.options)
341
+
342
+ if (this.modal.dispatchLoad) {
343
+ this.modal.dispatchLoad(this.modalOptions, event)
344
+ }
345
+ }
346
+ } else {
347
+ this.historyEnabled = false // Hiding modal using forward / back button, we disable the history to prevent state duplication
348
+
349
+ // Reload the page if the initial state has been inside modal. This prevents snippets loss e.g. during layout changes.
350
+ if (this.isPdModalState(this.initialState)) {
351
+ window.location.reload()
352
+ }
353
+
354
+ // Non-modal state and non-modal initial state, we just hide the current modal.
355
+ this.modal.hide(event)
356
+
357
+ // We don't want the naja popstate callback to be executed (or any other popstate handler).
358
+ event.stopImmediatePropagation()
359
+ }
360
+
361
+ // Keep track of current state. When `backwards` history mode is used, we eventually push this state into
362
+ // `this.original`.
363
+ this.lastState = {
364
+ location: location.href,
365
+ state: state,
366
+ title: document.title
367
+ }
368
+ }
369
+
370
+ private cleanData(): void {
371
+ this.historyEnabled = false
372
+ this.historyDirection = 'backwards'
373
+ this.modalOptions = null
374
+ }
375
+ }
@@ -0,0 +1,132 @@
1
+ import { CompleteEvent, Extension, Naja, StartEvent } from 'naja/dist/Naja'
2
+ import { InteractionEvent } from 'naja/dist/core/UIHandler'
3
+
4
+ /**
5
+ * @author Radek Šerý
6
+ *
7
+ * Spinner - loading indicator:
8
+ * 1. Extension can be turned off by using `data-naja-spinner="off"`.
9
+ * 2. If there is `data-naja-spinner` with different value, this value is used as a selector for element into which the spinner element is appended.
10
+ * 3. If there is no `data-naja-spinner`, closest `ajaxSpinnerWrapSelector` is being searched for and:
11
+ * i. If there is `ajaxSpinnerPlaceholderSelector` inside, this element is used for placing spinner element.
12
+ * ii. If not, the spinner element is appended into `ajaxSpinnerWrapSelector` itself.
13
+ */
14
+
15
+ declare module 'naja/dist/Naja' {
16
+ interface Options {
17
+ spinnerInitiator?: Element
18
+ }
19
+ }
20
+
21
+ type spinnerType = ((props?: any) => Element) | Element
22
+ type spinnerPropsFn = ((initiator: Element) => any) | undefined
23
+
24
+ export class SpinnerExtension implements Extension {
25
+ public readonly spinner: spinnerType
26
+ public readonly getSpinnerProps?: spinnerPropsFn
27
+
28
+ public readonly ajaxSpinnerWrapSelector: string
29
+ public readonly ajaxSpinnerPlaceholderSelector: string
30
+
31
+ public constructor(
32
+ spinner: spinnerType,
33
+ getSpinnerProps: spinnerPropsFn = undefined,
34
+ ajaxSpinnerWrapSelector = '.ajax-wrap',
35
+ ajaxSpinnerPlaceholderSelector = '.ajax-spinner'
36
+ ) {
37
+ this.spinner = spinner
38
+ this.getSpinnerProps = getSpinnerProps
39
+
40
+ this.ajaxSpinnerWrapSelector = ajaxSpinnerWrapSelector
41
+ this.ajaxSpinnerPlaceholderSelector = ajaxSpinnerPlaceholderSelector
42
+ }
43
+
44
+ public initialize(naja: Naja): void {
45
+ naja.uiHandler.addEventListener('interaction', this.getSpinnerInitiator.bind(this))
46
+
47
+ naja.addEventListener('start', this.showSpinners.bind(this))
48
+ naja.addEventListener('complete', this.hideSpinners.bind(this))
49
+ }
50
+
51
+ private getSpinnerInitiator(event: InteractionEvent): void {
52
+ event.detail.options.spinnerInitiator = event.detail.element
53
+ }
54
+
55
+ private showSpinners(event: StartEvent): void {
56
+ const { options } = event.detail
57
+
58
+ if (!options.spinnerInitiator) {
59
+ return
60
+ }
61
+
62
+ const spinnerInitiator = options.spinnerInitiator
63
+ const placeholders = this.getPlaceholders(spinnerInitiator)
64
+
65
+ if (placeholders.length === 0) {
66
+ return
67
+ } else {
68
+ options.spinnerQueue = options.spinnerQueue || []
69
+
70
+ placeholders.forEach((placeholder) => {
71
+ let spinner: Element
72
+
73
+ if (typeof this.spinner === 'function') {
74
+ spinner = this.getSpinnerProps ? this.spinner(this.getSpinnerProps(spinnerInitiator)) : this.spinner()
75
+ } else {
76
+ spinner = this.spinner
77
+ }
78
+
79
+ placeholder.appendChild(spinner)
80
+ options.spinnerQueue.push(spinner)
81
+
82
+ spinner.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 100 })
83
+ })
84
+ }
85
+ }
86
+
87
+ private hideSpinners(event: CompleteEvent): void {
88
+ const { options } = event.detail
89
+
90
+ if (options.forceRedirect) {
91
+ return
92
+ }
93
+
94
+ options.spinnerQueue?.forEach((spinner: Element) => {
95
+ const animation = spinner.animate({ opacity: 0 }, { duration: 100 })
96
+ animation.finished.then(() => spinner.remove())
97
+ })
98
+ }
99
+
100
+ private getPlaceholders(element: Element): Element[] {
101
+ if (!element) {
102
+ return []
103
+ }
104
+
105
+ const spinner = element.getAttribute('data-naja-spinner') || null
106
+ let placeholders: Element[] = []
107
+
108
+ if (spinner === 'off') {
109
+ return []
110
+ }
111
+
112
+ placeholders = this.getPlaceholdersByQuerySelector(spinner)
113
+
114
+ return placeholders.length ? placeholders : this.getPlaceholdersByDOM(element)
115
+ }
116
+
117
+ private getPlaceholdersByQuerySelector(selector: string | null): Element[] {
118
+ return selector ? Array.from(document.querySelectorAll(selector)) : []
119
+ }
120
+
121
+ private getPlaceholdersByDOM(element: Element): Element[] {
122
+ const wrap = element.closest(this.ajaxSpinnerWrapSelector)
123
+
124
+ if (wrap === null) {
125
+ return []
126
+ }
127
+
128
+ const placeholders = wrap.querySelectorAll(this.ajaxSpinnerPlaceholderSelector)
129
+
130
+ return placeholders.length ? Array.from(placeholders) : [wrap]
131
+ }
132
+ }
@@ -0,0 +1,6 @@
1
+ import ControlManager from './utils/ControlManager'
2
+
3
+ export { AjaxModalExtension } from './extensions/AjaxModalExtension'
4
+ export { SpinnerExtension } from './extensions/SpinnerExtension'
5
+
6
+ export const controlManager = new ControlManager()
@@ -0,0 +1,18 @@
1
+ // `Control` is meant to be used for standalone components, that might be dependent on ajax. It should be used together
2
+ // with ControlManager. Class implementing the `Control` interface should export its instance. Then the intended
3
+ // lifecycle of class is as follows:
4
+ //
5
+ // 1. `constructor` is called immediately and is also called only once. This is the ideal place for e.g. adding common
6
+ // event handlers to the `body`, or modifying necessary DOM properties on elements not affected by ajax.
7
+
8
+ // 2. The instantiated class is then added to ControlManager either by `addControlOnLoad` or `addControlOnLive`. This
9
+ // ensures, that on `DOMContentLoaded` the `initialize` function of class is called. This method should implement
10
+ // initialization of the control dependent on fully loaded DOM. The `context` argument is equal to `document` in this
11
+ // call.
12
+ //
13
+ // 3. In case of `addControlOnLive` used, the `initialize` method is also called for each success ajax request. The
14
+ // `context` argument is equal to modified nette snippet.
15
+ //
16
+ export default interface Control {
17
+ initialize(context: Element | Document): void
18
+ }
@@ -0,0 +1,42 @@
1
+ import naja from 'naja'
2
+ import { AfterUpdateEvent } from 'naja/dist/core/SnippetHandler'
3
+ import Control from './Control'
4
+
5
+ let instance: ControlManager | null = null
6
+
7
+ export default class ControlManager {
8
+ private onLoadControl: Control[] = []
9
+ private onLiveControl: Control[] = []
10
+
11
+ public constructor() {
12
+ if (instance === null) {
13
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
14
+ instance = this
15
+ naja.snippetHandler.addEventListener('afterUpdate', this.onSnippetUpdate.bind(this))
16
+ }
17
+ return instance
18
+ }
19
+
20
+ public onLoad(): void {
21
+ this.initialize(this.onLoadControl)
22
+ this.initialize(this.onLiveControl)
23
+ }
24
+
25
+ private onSnippetUpdate(event: AfterUpdateEvent): void {
26
+ this.initialize(this.onLiveControl, event.detail.snippet)
27
+ }
28
+
29
+ private initialize(controls: Control[], snippet?: Element | Document): void {
30
+ controls.forEach((control) => {
31
+ control.initialize(snippet || document)
32
+ })
33
+ }
34
+
35
+ public addControlOnLoad(control: Control): void {
36
+ this.onLoadControl.push(control)
37
+ }
38
+
39
+ public addControlOnLive(control: Control): void {
40
+ this.onLiveControl.push(control)
41
+ }
42
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2021",
4
+ "module": "ES2015",
5
+ "moduleResolution": "node",
6
+ "esModuleInterop": true,
7
+
8
+ "strict": true,
9
+ "alwaysStrict": true,
10
+
11
+ "rootDir": "src/",
12
+ "sourceMap": true,
13
+
14
+ "noEmitOnError": true,
15
+ "jsx": "react"
16
+ },
17
+ "exclude": [
18
+ "node_modules"
19
+ ]
20
+ }