@peckadesign/pd-naja 1.6.0 → 2.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.
@@ -0,0 +1,215 @@
1
+ import { SpinnerPropsFn, SpinnerType, WithSpinner } from '../types'
2
+ import { hideSpinner, showSpinner } from '../utils'
3
+
4
+ type SuggestOptions = {
5
+ minLength: number
6
+ timeout: number
7
+ }
8
+
9
+ export class Suggest {
10
+ public readonly spinner?: SpinnerType
11
+ public readonly getSpinnerProps?: SpinnerPropsFn
12
+
13
+ public static readonly className = 'js-suggest'
14
+ public readonly inputClassName = `${Suggest.className}__input`
15
+ public readonly buttonClassName = `${Suggest.className}__btn`
16
+ public readonly suggestClassName = `${Suggest.className}__suggest`
17
+ public readonly linkClassName = `${Suggest.className}__link`
18
+
19
+ private readonly form: HTMLFormElement
20
+ private readonly input: HTMLInputElement
21
+ private readonly button: HTMLButtonElement
22
+ private readonly suggest: HTMLElement
23
+
24
+ private suggestSpinner: Element | undefined
25
+
26
+ private isOpen: boolean = false
27
+ private timer: ReturnType<typeof setTimeout> | undefined = undefined
28
+
29
+ private lastSearched: string = ''
30
+
31
+ private readonly options: SuggestOptions = {
32
+ minLength: 2,
33
+ timeout: 200
34
+ }
35
+
36
+ public constructor(
37
+ form: HTMLFormElement,
38
+ options: Partial<SuggestOptions> = {},
39
+ spinner: SpinnerType | undefined = undefined,
40
+ getSpinnerProps: SpinnerPropsFn = undefined
41
+ ) {
42
+ this.form = form
43
+ this.spinner = spinner
44
+ this.getSpinnerProps = getSpinnerProps
45
+
46
+ const input = form.querySelector<HTMLInputElement>(`.${this.inputClassName}`)
47
+ const button = form.querySelector<HTMLButtonElement>(`.${this.buttonClassName}`)
48
+ const suggest = form.querySelector<HTMLElement>(`.${this.suggestClassName}`)
49
+
50
+ if (!input || !button || !suggest) {
51
+ throw new Error('Suggest: Missing input, button or suggest element.')
52
+ }
53
+
54
+ this.input = input
55
+ this.button = button
56
+ this.suggest = suggest
57
+
58
+ this.options = { ...this.options, ...options }
59
+
60
+ this.input.addEventListener('focus', this.showSuggest.bind(this))
61
+ this.input.addEventListener('blur', this.hideSuggest.bind(this))
62
+
63
+ this.input.addEventListener('keydown', this.handleInputKeydown.bind(this))
64
+ this.input.addEventListener('keyup', this.handleInputKeyup.bind(this))
65
+
66
+ this.suggest.addEventListener('mousedown', this.handleSuggestMousedown.bind(this))
67
+
68
+ this.form._suggest = this
69
+ }
70
+
71
+ private isEmpty(): boolean {
72
+ return this.suggest.childElementCount === 0
73
+ }
74
+
75
+ private showSuggest(): void {
76
+ if (this.isEmpty() || this.input.value.length < this.options.minLength) {
77
+ return
78
+ }
79
+
80
+ const event = new CustomEvent('show.suggest', { bubbles: true, cancelable: true })
81
+ this.suggest.dispatchEvent(event)
82
+
83
+ if (event.defaultPrevented) {
84
+ return
85
+ }
86
+
87
+ this.isOpen = true
88
+ this.suggest.classList.add(`${this.suggestClassName}--shown`)
89
+ }
90
+
91
+ private hideSuggest(): void {
92
+ const event = new CustomEvent('hide.suggest', { bubbles: true, cancelable: true })
93
+ this.suggest.dispatchEvent(event)
94
+
95
+ if (event.defaultPrevented) {
96
+ return
97
+ }
98
+
99
+ this.isOpen = false
100
+ this.suggest.classList.remove(`${this.suggestClassName}--shown`)
101
+ }
102
+
103
+ private emptySuggest(): void {
104
+ this.suggest.classList.add(`${this.suggestClassName}--empty`)
105
+ this.suggest.replaceChildren()
106
+ }
107
+
108
+ public startSuggest(): void {
109
+ this.suggest.dispatchEvent(new CustomEvent('loading.suggest', { bubbles: true }))
110
+ this.input.classList.add(`${this.inputClassName}--loading`)
111
+
112
+ if (this.spinner && !this.suggestSpinner) {
113
+ this.suggestSpinner = showSpinner.call(this as WithSpinner, this.form)
114
+ }
115
+ }
116
+
117
+ public finishSuggest(): void {
118
+ this.suggest.dispatchEvent(new CustomEvent('loaded.suggest', { bubbles: true }))
119
+ this.input.classList.remove(`${this.inputClassName}--loading`)
120
+
121
+ this.suggest.classList.toggle(`${this.suggestClassName}--empty`, this.isEmpty())
122
+
123
+ if (this.isEmpty() || document.activeElement !== this.input) {
124
+ this.hideSuggest()
125
+ } else {
126
+ this.showSuggest()
127
+ }
128
+
129
+ if (this.suggestSpinner) {
130
+ hideSpinner(this.suggestSpinner)
131
+ this.suggestSpinner = undefined
132
+ }
133
+ }
134
+
135
+ private handleInputKeydown(event: KeyboardEvent): void {
136
+ let anchors: HTMLAnchorElement[]
137
+ let activeAnchor: HTMLAnchorElement | null | undefined
138
+ let activeAnchorIndex: number | undefined
139
+ const activeClassName = `${this.linkClassName}--active`
140
+
141
+ switch (event.key) {
142
+ case 'Escape':
143
+ event.preventDefault()
144
+ this.hideSuggest()
145
+ this.input.blur()
146
+ break
147
+
148
+ case 'ArrowDown':
149
+ case 'ArrowUp':
150
+ event.preventDefault()
151
+
152
+ anchors = Array.from(this.suggest.querySelectorAll<HTMLAnchorElement>(`.${this.linkClassName}`))
153
+ activeAnchor = anchors.find((element) => element.classList.contains(activeClassName))
154
+ activeAnchorIndex = activeAnchor ? anchors.indexOf(activeAnchor) : undefined
155
+
156
+ activeAnchor?.classList.remove(activeClassName)
157
+
158
+ if (event.key === 'ArrowDown') {
159
+ activeAnchorIndex = activeAnchorIndex !== undefined ? activeAnchorIndex + 1 : 0
160
+ } else {
161
+ activeAnchorIndex = activeAnchorIndex !== undefined ? activeAnchorIndex - 1 : anchors.length - 1
162
+ }
163
+
164
+ anchors[activeAnchorIndex]?.classList.add(activeClassName)
165
+
166
+ break
167
+
168
+ case 'Enter':
169
+ activeAnchor = this.suggest.querySelector<HTMLAnchorElement>(`.${activeClassName}`)
170
+
171
+ if (activeAnchor) {
172
+ event.preventDefault()
173
+ event.stopPropagation()
174
+
175
+ location.href = activeAnchor.href
176
+ }
177
+ break
178
+ }
179
+ }
180
+
181
+ private handleInputKeyup(): void {
182
+ clearTimeout(this.timer)
183
+
184
+ const query = this.input.value
185
+
186
+ if (query.length < this.options.minLength) {
187
+ this.hideSuggest()
188
+ return
189
+ }
190
+
191
+ if (!this.isOpen) {
192
+ // If the suggestion wasn't open and this query is different from the last query, we need to clear the
193
+ // results because they are not relevant. This only needs to be done if the suggestion has been closed.
194
+ if (query !== this.lastSearched) {
195
+ this.emptySuggest()
196
+ }
197
+
198
+ this.showSuggest()
199
+ }
200
+
201
+ // If the query is the same, there's nothing more to do.
202
+ if (query === this.lastSearched) {
203
+ return
204
+ }
205
+
206
+ this.timer = setTimeout(() => {
207
+ this.lastSearched = query
208
+ this.button.click()
209
+ }, this.options.timeout)
210
+ }
211
+
212
+ private handleSuggestMousedown(event: MouseEvent): void {
213
+ event.preventDefault()
214
+ }
215
+ }
@@ -1,6 +1,7 @@
1
1
  import { InteractionEvent } from 'naja/dist/core/UIHandler'
2
2
  import { CompleteEvent, Extension, Naja, StartEvent } from 'naja/dist/Naja'
3
- import { isDatasetTruthy } from '../utils'
3
+ import { SpinnerPropsFn, SpinnerType, WithSpinner } from '../types'
4
+ import { hideSpinner, isDatasetTruthy, showSpinner } from '../utils'
4
5
 
5
6
  declare module 'naja/dist/Naja' {
6
7
  interface Options {
@@ -9,15 +10,12 @@ declare module 'naja/dist/Naja' {
9
10
  }
10
11
  }
11
12
 
12
- type spinnerType = ((props?: any) => Element) | Element
13
- type spinnerPropsFn = ((initiator: Element) => any) | undefined
14
-
15
- export class BtnSpinnerExtension implements Extension {
13
+ export class BtnSpinnerExtension implements Extension, WithSpinner {
16
14
  public readonly timeout: number
17
- public readonly spinner: spinnerType
18
- public readonly getSpinnerProps?: spinnerPropsFn
15
+ public readonly spinner: SpinnerType
16
+ public readonly getSpinnerProps: SpinnerPropsFn
19
17
 
20
- public constructor(spinner: spinnerType, getSpinnerProps: spinnerPropsFn = undefined, timeout = 60000) {
18
+ public constructor(spinner: SpinnerType, getSpinnerProps: SpinnerPropsFn = undefined, timeout = 60000) {
21
19
  this.spinner = spinner
22
20
  this.getSpinnerProps = getSpinnerProps
23
21
  this.timeout = timeout
@@ -36,11 +34,11 @@ export class BtnSpinnerExtension implements Extension {
36
34
  return true
37
35
  }
38
36
 
39
- const spinner = this.showSpinner(button)
37
+ const spinner = showSpinner.call(this, button)
40
38
 
41
39
  form.dataset.btnSpinnerTimeout = String(
42
40
  setTimeout(() => {
43
- this.hideSpinner(spinner)
41
+ hideSpinner(spinner)
44
42
  delete form.dataset.btnSpinnerTimeout
45
43
  }, this.timeout)
46
44
  )
@@ -70,7 +68,7 @@ export class BtnSpinnerExtension implements Extension {
70
68
  return
71
69
  }
72
70
 
73
- options.btnSpinner = this.showSpinner(options.btnSpinnerInitiator)
71
+ options.btnSpinner = showSpinner.call(this, options.btnSpinnerInitiator)
74
72
  }
75
73
 
76
74
  private handleCompleteEvent(event: CompleteEvent): void {
@@ -80,26 +78,6 @@ export class BtnSpinnerExtension implements Extension {
80
78
  return
81
79
  }
82
80
 
83
- this.hideSpinner(options.btnSpinner)
84
- }
85
-
86
- private showSpinner(button: Element): Element {
87
- let spinner: Element
88
-
89
- if (typeof this.spinner === 'function') {
90
- spinner = this.getSpinnerProps ? this.spinner(this.getSpinnerProps(button)) : this.spinner()
91
- } else {
92
- spinner = this.spinner
93
- }
94
-
95
- button.appendChild(spinner)
96
- spinner.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 100 })
97
-
98
- return spinner
99
- }
100
-
101
- private hideSpinner(spinner: Element): void {
102
- const animation = spinner.animate({ opacity: 0 }, { duration: 100 })
103
- animation.finished.then(() => spinner?.remove())
81
+ hideSpinner(options.btnSpinner)
104
82
  }
105
83
  }
@@ -1,5 +1,7 @@
1
1
  import { CompleteEvent, Extension, Naja, StartEvent } from 'naja/dist/Naja'
2
2
  import { InteractionEvent } from 'naja/dist/core/UIHandler'
3
+ import { SpinnerPropsFn, SpinnerType, WithSpinner } from '../types'
4
+ import { hideSpinner, showSpinner } from '../utils'
3
5
 
4
6
  /**
5
7
  * @author Radek Šerý
@@ -21,19 +23,16 @@ declare module 'naja/dist/Naja' {
21
23
  }
22
24
  }
23
25
 
24
- type spinnerType = ((props?: any) => Element) | Element
25
- type spinnerPropsFn = ((initiator: Element) => any) | undefined
26
-
27
- export class SpinnerExtension implements Extension {
28
- public readonly spinner: spinnerType
29
- public readonly getSpinnerProps?: spinnerPropsFn
26
+ export class SpinnerExtension implements Extension, WithSpinner {
27
+ public readonly spinner: SpinnerType
28
+ public readonly getSpinnerProps: SpinnerPropsFn
30
29
 
31
30
  public readonly ajaxSpinnerWrapSelector: string
32
31
  public readonly ajaxSpinnerPlaceholderSelector: string
33
32
 
34
33
  public constructor(
35
- spinner: spinnerType,
36
- getSpinnerProps: spinnerPropsFn = undefined,
34
+ spinner: SpinnerType,
35
+ getSpinnerProps: SpinnerPropsFn = undefined,
37
36
  ajaxSpinnerWrapSelector = '.ajax-wrap',
38
37
  ajaxSpinnerPlaceholderSelector = '.ajax-spinner'
39
38
  ) {
@@ -71,18 +70,7 @@ export class SpinnerExtension implements Extension {
71
70
  options.spinnerQueue = options.spinnerQueue || []
72
71
 
73
72
  placeholders.forEach((placeholder) => {
74
- let spinner: Element
75
-
76
- if (typeof this.spinner === 'function') {
77
- spinner = this.getSpinnerProps ? this.spinner(this.getSpinnerProps(spinnerInitiator)) : this.spinner()
78
- } else {
79
- spinner = this.spinner
80
- }
81
-
82
- placeholder.appendChild(spinner)
83
- options.spinnerQueue.push(spinner)
84
-
85
- spinner.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 100 })
73
+ options.spinnerQueue.push(showSpinner.call(this, placeholder, spinnerInitiator))
86
74
  })
87
75
  }
88
76
  }
@@ -94,10 +82,7 @@ export class SpinnerExtension implements Extension {
94
82
  return
95
83
  }
96
84
 
97
- options.spinnerQueue?.forEach((spinner: Element) => {
98
- const animation = spinner.animate({ opacity: 0 }, { duration: 100 })
99
- animation.finished.then(() => spinner.remove())
100
- })
85
+ options.spinnerQueue?.forEach((spinner: Element) => hideSpinner(spinner))
101
86
  }
102
87
 
103
88
  private getPlaceholders(element: Element): Element[] {
@@ -0,0 +1,66 @@
1
+ import { InteractionEvent } from 'naja/dist/core/UIHandler'
2
+ import { CompleteEvent, Extension, Naja, StartEvent } from 'naja/dist/Naja'
3
+ import { Suggest } from '../classes/Suggest'
4
+ import { SpinnerPropsFn, SpinnerType } from '../types'
5
+
6
+ declare module 'naja/dist/Naja' {
7
+ interface Options {
8
+ suggest?: Suggest
9
+ }
10
+ }
11
+
12
+ export class SuggestExtension implements Extension {
13
+ private requestQueue: Set<Request> = new Set()
14
+
15
+ public readonly spinner: SpinnerType | undefined
16
+ public readonly getSpinnerProps?: SpinnerPropsFn
17
+
18
+ public constructor(spinner: SpinnerType | undefined = undefined, getSpinnerProps: SpinnerPropsFn = undefined) {
19
+ this.spinner = spinner
20
+ this.getSpinnerProps = getSpinnerProps
21
+
22
+ const forms = document.querySelectorAll<HTMLFormElement>(`.${Suggest.className}`)
23
+
24
+ forms.forEach((form) => {
25
+ new Suggest(form, {}, spinner, getSpinnerProps)
26
+ })
27
+ }
28
+
29
+ public initialize(naja: Naja): void {
30
+ naja.uiHandler.addEventListener('interaction', this.checkExtensionEnabled.bind(this))
31
+ naja.addEventListener('start', this.start.bind(this))
32
+ naja.addEventListener('complete', this.complete.bind(this))
33
+ }
34
+
35
+ private checkExtensionEnabled(event: InteractionEvent): void {
36
+ const { element, options } = event.detail
37
+
38
+ const inputElement = element as HTMLInputElement
39
+ if (inputElement.form && inputElement.form._suggest) {
40
+ options.suggest = inputElement.form._suggest
41
+ }
42
+ }
43
+
44
+ private start(event: StartEvent): void {
45
+ const { options, request } = event.detail
46
+
47
+ if (options.suggest) {
48
+ this.requestQueue.add(request)
49
+ options.suggest.startSuggest()
50
+ }
51
+ }
52
+
53
+ private complete(event: CompleteEvent): void {
54
+ const { options, request } = event.detail
55
+
56
+ if (!options.suggest) {
57
+ return
58
+ }
59
+
60
+ this.requestQueue.delete(request)
61
+
62
+ if (this.requestQueue.size === 0) {
63
+ options.suggest.finishSuggest()
64
+ }
65
+ }
66
+ }
@@ -0,0 +1,32 @@
1
+ import { Extension, Naja } from 'naja/dist/Naja'
2
+ import { BeforeUpdateEvent } from 'naja/dist/core/SnippetHandler'
3
+
4
+ type SnippetUpdateOperation = (snippet: Element, content: string) => void
5
+
6
+ export class ViewTransitionExtension implements Extension {
7
+ private operation: SnippetUpdateOperation | undefined
8
+
9
+ public initialize(naja: Naja): void {
10
+ naja.snippetHandler.addEventListener('beforeUpdate', this.handleBeforeUpdate.bind(this))
11
+ }
12
+
13
+ private handleBeforeUpdate(event: BeforeUpdateEvent): void {
14
+ const { operation, changeOperation } = event.detail
15
+
16
+ if (document.startViewTransition !== undefined) {
17
+ this.operation = operation
18
+ changeOperation(this.replace.bind(this))
19
+ }
20
+ }
21
+
22
+ public replace(snippet: Element, content: string): ViewTransition {
23
+ return document.startViewTransition(() => {
24
+ if (!this.operation) {
25
+ return
26
+ }
27
+
28
+ this.operation(snippet, content)
29
+ this.operation = undefined
30
+ })
31
+ }
32
+ }
package/src/index.esm.ts CHANGED
@@ -1,4 +1,5 @@
1
- import ControlManager from './utils/ControlManager'
1
+ export { ControlManager } from './classes/ControlManager'
2
+ export { Suggest } from './classes/Suggest'
2
3
 
3
4
  export { AjaxModalExtension } from './extensions/AjaxModalExtension'
4
5
  export { AjaxModalPreventRedrawExtension } from './extensions/AjaxModalPreventRedrawExtension'
@@ -12,8 +13,7 @@ export { ScrollToExtension } from './extensions/ScrollToExtension'
12
13
  export { SingleSubmitExtension } from './extensions/SingleSubmitExtension'
13
14
  export { SnippetFormPartExtension } from './extensions/SnippetFormPartExtension'
14
15
  export { SpinnerExtension } from './extensions/SpinnerExtension'
16
+ export { SuggestExtension } from './extensions/SuggestExtension'
15
17
  export { ToggleClassExtension } from './extensions/ToggleClassExtension'
16
18
 
17
- export { isDatasetTruthy, isDatasetFalsy } from './utils'
18
-
19
- export const controlManager = new ControlManager()
19
+ export { isDatasetFalsy, isDatasetTruthy, showSpinner, hideSpinner } from './utils'
@@ -1,3 +1,11 @@
1
+ export type SpinnerType = ((props?: any) => Element) | Element
2
+ export type SpinnerPropsFn = ((initiator: Element) => any) | undefined
3
+
4
+ export interface WithSpinner {
5
+ spinner: SpinnerType
6
+ getSpinnerProps: SpinnerPropsFn
7
+ }
8
+
1
9
  // `Control` is meant to be used for standalone components, that might be dependent on ajax. It should be used together
2
10
  // with ControlManager. Class implementing the `Control` interface should export its instance. Then the intended
3
11
  // lifecycle of class is as follows:
@@ -16,8 +24,7 @@
16
24
  //
17
25
  // 2. The `initialize` method is called for each snippet. It is called immediately after the snippet has been
18
26
  // updated. The `context` argument is equal to the modified nette snippet.
19
-
20
- export default interface Control {
27
+ export interface Control {
21
28
  initialize(context: Element | Document): void
22
29
 
23
30
  destroy?(context: Element): void
package/src/utils.ts CHANGED
@@ -1,3 +1,25 @@
1
+ import { WithSpinner } from './types'
2
+
3
+ export function showSpinner(this: WithSpinner, target: Element, initiator: Element = target): Element {
4
+ let spinner: Element
5
+
6
+ if (typeof this.spinner === 'function') {
7
+ spinner = this.getSpinnerProps ? this.spinner(this.getSpinnerProps(initiator)) : this.spinner()
8
+ } else {
9
+ spinner = this.spinner
10
+ }
11
+
12
+ target.appendChild(spinner)
13
+ spinner.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 100 })
14
+
15
+ return spinner
16
+ }
17
+
18
+ export function hideSpinner(spinner: Element): void {
19
+ const animation = spinner.animate({ opacity: 0 }, { duration: 100 })
20
+ animation.finished.then(() => spinner?.remove())
21
+ }
22
+
1
23
  export const isDatasetTruthy = (element: Element, datasetName: string): boolean => {
2
24
  const datasetValue = (element as HTMLElement).dataset[datasetName]
3
25
 
@@ -1,4 +0,0 @@
1
- export default interface Control {
2
- initialize(context: Element | Document): void;
3
- destroy?(context: Element): void;
4
- }