jj 3.0.0-rc.5 → 3.0.0-rc.7
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/package.json +1 -1
- package/skills/SKILL.md +230 -72
- package/skills/references/css-improvements.md +4 -3
- package/skills/references/eventing-patterns.md +15 -9
- package/skills/references/jquery-to-jj-translation.md +10 -7
- package/skills/references/lit-to-jj-translation.md +5 -5
- package/skills/references/querying-patterns.md +19 -16
- package/skills/references/react-to-jj-translation.md +5 -5
- package/skills/references/svelte-to-jj-translation.md +3 -3
- package/skills/references/testing-with-jsdom.md +6 -6
- package/skills/references/vue-to-jj-translation.md +6 -6
- package/skills/references/web-components-patterns.md +13 -7
package/package.json
CHANGED
package/skills/SKILL.md
CHANGED
|
@@ -7,6 +7,42 @@ description: Expert guide for the JJ DOM manipulation library. Load this skill w
|
|
|
7
7
|
|
|
8
8
|
JJ is a minimal, zero-dependency TypeScript library that wraps browser DOM interfaces in fluent, type-safe classes. It complements native browser APIs rather than replacing them.
|
|
9
9
|
|
|
10
|
+
## Translation Checklist
|
|
11
|
+
|
|
12
|
+
When converting native DOM code, framework code, or vague UI requests into JJ, default to this order of thought:
|
|
13
|
+
|
|
14
|
+
- Start from wrappers, not native nodes: `JJD.from(document)`, `JJHE.create()`, `JJHE.tree()`, `JJET.from(window)`, `getShadow(true)`.
|
|
15
|
+
- Keep values wrapped and chain operations; use `.ref` only for native APIs JJ does not provide.
|
|
16
|
+
- Use `JJHE.tree` with a local `h` alias for multi-node or nested UI.
|
|
17
|
+
- Use `setChild()`/`setChildren()` to replace content and `addChildMap()`/`setChildMap()` for array rendering.
|
|
18
|
+
- Query with `find()`/`findAll()`/`closest()` instead of native `querySelector*` when JJ already covers the case.
|
|
19
|
+
- For form-like value elements (`input`, `select`, `textarea`, `progress`, etc.), prefer `getValue()` / `setValue(...)` over `.ref.value`.
|
|
20
|
+
- Use `setText()` for user content and treat `setHTML(..., true)` as a trusted-content escape hatch.
|
|
21
|
+
- For repeated child interactions, prefer one delegated listener on a stable parent over one listener per child.
|
|
22
|
+
- Choose shadow DOM for self-contained widgets and light DOM for page-level content that should inherit global styles.
|
|
23
|
+
- For components, keep fetched templates/styles at module scope, attach shadow root in the constructor, initialize once, then update targeted nodes instead of rebuilding the whole tree.
|
|
24
|
+
- Prefer `'open'` shadow roots unless the user explicitly needs `'closed'`; open mode is easier to test and debug.
|
|
25
|
+
- Use plain JS state plus targeted wrapper updates by default; do not invent a virtual DOM style rerender loop unless the user explicitly wants one.
|
|
26
|
+
|
|
27
|
+
## Naming Conventions
|
|
28
|
+
|
|
29
|
+
In this repository, prefix variables that hold JJ wrapper instances with `jj`.
|
|
30
|
+
|
|
31
|
+
```typescript
|
|
32
|
+
const jjDoc = JJD.from(document)
|
|
33
|
+
const jjFruits = jjDoc.find('#fruits', true)
|
|
34
|
+
const jjSubmitBtn = jjDoc.find('button#submit', true)
|
|
35
|
+
const jjDialog = JJHE.create('dialog')
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Naming defaults:
|
|
39
|
+
|
|
40
|
+
- Use `jj*` for JJ wrappers, including private fields: `#jjHost` for `JJHE.from(this)` (the wrapped host element) and `#jjShadow` for `this.#jjHost.getShadow()` (the wrapped shadow root).
|
|
41
|
+
- Do not use `jj*` for plain data like `fruits`, `title`, `isOpen`, or `userName`.
|
|
42
|
+
- Do not use `jj*` for native DOM values; prefer names like `formEl`, `shadowRoot`, `inputRef`, or `styleSheet`.
|
|
43
|
+
- For promises, use normal names with `Promise`, like `templatePromise` or `stylePromise`.
|
|
44
|
+
- `h` is the main intentional exception: use it as the local alias for `JJHE.tree`.
|
|
45
|
+
|
|
10
46
|
## Wrapper Hierarchy
|
|
11
47
|
|
|
12
48
|
Each JJ wrapper exposes the native node via `.ref`.
|
|
@@ -29,12 +65,12 @@ Each JJ wrapper exposes the native node via `.ref`.
|
|
|
29
65
|
|
|
30
66
|
```typescript
|
|
31
67
|
// ✅ CORRECT — factory methods infer the precise generic type
|
|
32
|
-
const
|
|
33
|
-
const
|
|
34
|
-
const
|
|
35
|
-
const
|
|
36
|
-
const
|
|
37
|
-
const
|
|
68
|
+
const jjDiv = JJHE.create('div') // JJHE<HTMLDivElement>
|
|
69
|
+
const jjInput = JJHE.create('input') // JJHE<HTMLInputElement>
|
|
70
|
+
const jjSvg = JJSE.create('svg') // JJSE<SVGSVGElement>
|
|
71
|
+
const jjMath = JJME.create('math') // JJME<MathMLElement>
|
|
72
|
+
const jjFrag = JJDF.create() // JJDF
|
|
73
|
+
const jjBtn = JJHE.fromId('my-btn') // JJHE<HTMLButtonElement>
|
|
38
74
|
|
|
39
75
|
// ❌ WRONG
|
|
40
76
|
JJHE.create('svg') // throws — use JJSE.create('svg')
|
|
@@ -46,7 +82,7 @@ new JJHE(element) // don't call constructors directly
|
|
|
46
82
|
All mutating methods return `this`. Chain as much as possible; access `.ref` only when a wrapper method does not exist.
|
|
47
83
|
|
|
48
84
|
```typescript
|
|
49
|
-
const
|
|
85
|
+
const jjBtn = JJHE.create('button')
|
|
50
86
|
.addClass('btn', 'primary')
|
|
51
87
|
.setText('Save')
|
|
52
88
|
.setAttr('type', 'submit')
|
|
@@ -54,98 +90,153 @@ const btn = JJHE.create('button')
|
|
|
54
90
|
.on('click', handleSave)
|
|
55
91
|
```
|
|
56
92
|
|
|
93
|
+
## Tutorial Defaults — Prefer JJ Idioms Over Native DOM Steps
|
|
94
|
+
|
|
95
|
+
When translating browser DOM code into JJ, do not mechanically keep native patterns like repeated `appendChild`, `querySelector`, or unwrap/re-wrap flows. Prefer the JJ equivalent that keeps work inside wrappers.
|
|
96
|
+
|
|
97
|
+
```typescript
|
|
98
|
+
// ✅ preferred: build a subtree once
|
|
99
|
+
const h = JJHE.tree
|
|
100
|
+
|
|
101
|
+
latestChatResponse.addChild(
|
|
102
|
+
h('section', null, h('h2', null, 'User'), h('div', null, userPrompt), h('h2', null, 'Assistant'), assistantMessage),
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
// ✅ also fine for flat mapped children
|
|
106
|
+
const jjList = JJHE.create('ul').addChildMap(fruits, (fruit) => h('li', null, fruit))
|
|
107
|
+
|
|
108
|
+
// ❌ avoid native-style wrapper escape hatches when JJ already covers it
|
|
109
|
+
latestChatResponse.ref.appendChild(JJHE.create('h2').setText('User').ref)
|
|
110
|
+
latestChatResponse.ref.appendChild(JJHE.create('div').setText(userPrompt).ref)
|
|
111
|
+
latestChatResponse.ref.appendChild(JJHE.create('h2').setText('Assistant').ref)
|
|
112
|
+
latestChatResponse.ref.appendChild(assistantMessage.ref)
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Default heuristics from the tutorial:
|
|
116
|
+
|
|
117
|
+
- Use `JJHE.tree` with a local `h` alias when creating multiple siblings or any nested subtree.
|
|
118
|
+
- Use `create()` for one-off elements; switch to `tree()` as soon as structure becomes non-trivial.
|
|
119
|
+
- Prefer `setChild()` or `setChildren()` when replacing content, not `.empty().addChild()`.
|
|
120
|
+
- Prefer `addChildMap()` or `setChildMap()` when rendering from arrays.
|
|
121
|
+
- Keep values wrapped. Reach for `.ref` only for native APIs JJ does not expose.
|
|
122
|
+
- Use JJ verb families consistently: `set*` replaces, `add*` appends, `pre*` prepends, `rm*` removes, `sw*` toggles.
|
|
123
|
+
|
|
57
124
|
## Document Queries
|
|
58
125
|
|
|
59
126
|
Wrap `document` with `JJD.from(document)` before querying.
|
|
60
127
|
|
|
61
128
|
```typescript
|
|
62
|
-
const
|
|
63
|
-
const
|
|
64
|
-
const
|
|
65
|
-
const
|
|
129
|
+
const jjDoc = JJD.from(document)
|
|
130
|
+
const jjApp = jjDoc.find('#app', true) // throws when absent
|
|
131
|
+
const jjCard = jjDoc.find('.card') // null when absent
|
|
132
|
+
const jjItems = jjDoc.findAll('.item') // always an array
|
|
66
133
|
|
|
67
134
|
// Inside a custom element's shadow root
|
|
68
|
-
const
|
|
135
|
+
const jjBtn = this.getShadow(true).find('#submit')
|
|
69
136
|
```
|
|
70
137
|
|
|
138
|
+
Querying defaults from the tutorial:
|
|
139
|
+
|
|
140
|
+
- Start from a wrapped container like `JJD.from(document)` or a `JJSR` shadow root.
|
|
141
|
+
- Prefer `find(selector, true)` when absence is a bug; it fails earlier and more clearly than a later null access.
|
|
142
|
+
- Prefer narrower selectors that encode expectations, like `button#submit`, instead of broad lookups plus manual type checks.
|
|
143
|
+
- Use `findAll()` for arrays of wrappers and keep operating on wrappers instead of unwrapping to native elements.
|
|
144
|
+
- Do not use `.ref.querySelector(...)` or `.ref.querySelectorAll(...)` when `find()` or `findAll()` already covers the case.
|
|
145
|
+
- Use `.closest()` on wrappers for event delegation and ancestor lookup.
|
|
146
|
+
- Use `JJHE.fromId('submit-btn')` for direct ID lookup when you already know the target is an HTML element.
|
|
147
|
+
|
|
71
148
|
## Attributes, Classes, Styles
|
|
72
149
|
|
|
73
150
|
```typescript
|
|
74
151
|
// Attribute — singular
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
152
|
+
jjEl.setAttr('role', 'button')
|
|
153
|
+
jjEl.getAttr('role')
|
|
154
|
+
jjEl.rmAttr('hidden')
|
|
155
|
+
jjEl.swAttr('readonly') // auto: flips current state of the "readonly" attribute
|
|
156
|
+
jjEl.swAttr('disabled', !isReady) // sets disabled="" or removes it
|
|
79
157
|
|
|
80
158
|
// Attribute — batch (null/undefined skipped)
|
|
81
|
-
|
|
159
|
+
jjEl.setAttrs({ type: 'text', placeholder: 'Search…' })
|
|
82
160
|
|
|
83
161
|
// Classes
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
162
|
+
jjEl.addClass('active')
|
|
163
|
+
// Multiple classes via varargs
|
|
164
|
+
jjEl.addClass('active', 'selected')
|
|
165
|
+
jjEl.rmClass('active', 'loading')
|
|
166
|
+
// Multiple classes via array
|
|
167
|
+
jjEl.addClasses(['chip', 'selected'])
|
|
168
|
+
jjEl.rmClasses(['pending', 'loading'])
|
|
169
|
+
// Explicit mode: truthy adds, falsy removes
|
|
170
|
+
jjEl.swClass('expanded', isExpanded)
|
|
171
|
+
// Auto mode: flips current state
|
|
172
|
+
jjEl.swClass('is-active')
|
|
173
|
+
|
|
174
|
+
// Batch conditional class updates
|
|
175
|
+
jjEl.setClasses({ active: isActive, disabled: !isReady })
|
|
176
|
+
// Replace the entire className
|
|
177
|
+
jjEl.setClass('card card--featured')
|
|
94
178
|
|
|
95
179
|
// Dataset
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
180
|
+
jjEl.getDataAttr('userId')
|
|
181
|
+
jjEl.hasDataAttr('userId')
|
|
182
|
+
jjEl.setDataAttr('userId', '42')
|
|
183
|
+
jjEl.setDataAttrs({ role: 'admin', team: 'ui' }) // batch set
|
|
184
|
+
jjEl.rmDataAttr('userId')
|
|
185
|
+
jjEl.rmDataAttr('role', 'team') // batch remove, varargs syntax
|
|
186
|
+
jjEl.rmDataAttrs(['role', 'team']) // batch remove, array syntax
|
|
102
187
|
|
|
103
188
|
// ARIA
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
189
|
+
jjEl.getAriaAttr('hidden')
|
|
190
|
+
jjEl.hasAriaAttr('hidden')
|
|
191
|
+
jjEl.setAriaAttr('hidden', 'true')
|
|
192
|
+
jjEl.setAriaAttrs({ label: 'Dialog', modal: 'true' })
|
|
193
|
+
jjEl.rmAriaAttr('hidden')
|
|
109
194
|
|
|
110
195
|
// ARIA is not presence-based like HTML boolean attributes
|
|
111
196
|
// Use explicit string states instead of swAttr()
|
|
112
|
-
|
|
197
|
+
jjEl.setAriaAttr('disabled', 'true')
|
|
113
198
|
|
|
114
199
|
// Inline styles
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
200
|
+
jjEl.setStyle('color', 'var(--color-brand)')
|
|
201
|
+
jjEl.setStyles({ color: 'red', padding: '8px', border: null })
|
|
202
|
+
jjEl.rmStyle('color', 'padding')
|
|
203
|
+
|
|
204
|
+
// Value helpers (prefer over .ref.value)
|
|
205
|
+
jjEl.getValue()
|
|
206
|
+
jjEl.setValue('next')
|
|
118
207
|
```
|
|
119
208
|
|
|
209
|
+
Use `.ref.value` only when a JJ value helper is unavailable for your exact use case.
|
|
210
|
+
|
|
120
211
|
## Security — HTML Writes
|
|
121
212
|
|
|
122
213
|
Prefer `.setText()` for any user-supplied content. `.setHTML()` requires an explicit `true` flag when the string is non-empty.
|
|
123
214
|
|
|
124
215
|
```typescript
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
216
|
+
jjEl.setText(userInput) // ✅ always safe
|
|
217
|
+
jjEl.setHTML('<p>Trusted markup</p>', true) // ✅ explicit opt-in
|
|
218
|
+
jjEl.setHTML('') // ✅ clearing is allowed without flag
|
|
219
|
+
jjEl.setHTML('<p>content</p>') // ❌ THROWS — missing unsafe flag
|
|
220
|
+
jjEl.ref.innerHTML = '…' // ❌ bypasses guard — avoid
|
|
130
221
|
```
|
|
131
222
|
|
|
132
223
|
## Events
|
|
133
224
|
|
|
134
225
|
```typescript
|
|
135
226
|
// Native events
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
227
|
+
jjEl.on('click', handler)
|
|
228
|
+
jjEl.off('click', handler)
|
|
229
|
+
jjEl.triggerEvent('click')
|
|
139
230
|
|
|
140
231
|
// Explicit event objects (equivalent to JJ helpers below)
|
|
141
|
-
|
|
142
|
-
|
|
232
|
+
jjEl.trigger(new Event('click', { bubbles: true, composed: true }))
|
|
233
|
+
jjEl.trigger(new CustomEvent('todo-toggle', { detail: { id: 1, done: true }, bubbles: true, composed: true }))
|
|
143
234
|
|
|
144
235
|
// Custom events — JJ defaults: bubbles: true, composed: true
|
|
145
236
|
this.dispatchEvent(new CustomEvent('todo-toggle', { detail: { id: 1, done: true }, bubbles: true, composed: true }))
|
|
146
237
|
|
|
147
238
|
// Fluent dispatch (same defaults)
|
|
148
|
-
|
|
239
|
+
jjEl.triggerEvent('click') // equivalent to trigger(new Event('click', { bubbles: true, composed: true }))
|
|
149
240
|
JJHE.from(this).triggerCustomEvent('todo-toggle', { id: 1, done: true })
|
|
150
241
|
// equivalent to trigger(new CustomEvent('todo-toggle', { detail: { id: 1, done: true }, bubbles: true, composed: true }))
|
|
151
242
|
|
|
@@ -153,10 +244,41 @@ JJHE.from(this).triggerCustomEvent('todo-toggle', { id: 1, done: true })
|
|
|
153
244
|
new CustomEvent('panel-ready', { bubbles: false, composed: false })
|
|
154
245
|
```
|
|
155
246
|
|
|
247
|
+
Event defaults from the tutorial:
|
|
248
|
+
|
|
249
|
+
- Prefer `.on()` and `.off()` on wrappers over native `addEventListener`/`removeEventListener` when already working with JJ values.
|
|
250
|
+
- Prefer `.triggerEvent()` and `.triggerCustomEvent()` for common JJ event dispatch; they default to `bubbles: true` and `composed: true`.
|
|
251
|
+
- Use `triggerCustomEvent(name, detail)` for component-to-parent communication instead of ad hoc callback plumbing.
|
|
252
|
+
- Use `bubbles: false` and `composed: false` only for intentionally internal events.
|
|
253
|
+
- Keep event code close to the wrapper it affects so later DOM updates stay targeted and local.
|
|
254
|
+
|
|
255
|
+
Guide defaults for event-heavy UI:
|
|
256
|
+
|
|
257
|
+
- Prefer event delegation on a common parent for repeated child actions instead of binding one listener per item.
|
|
258
|
+
- Use `.closest()` to recover the intended delegated target from `event.target`.
|
|
259
|
+
- When you need JJ's wrapper-bound `this` inside a listener, use `function` syntax, not an arrow.
|
|
260
|
+
- Native UI events like `click`, `input`, and `change` already cross shadow boundaries; custom events do not unless `composed: true`.
|
|
261
|
+
|
|
262
|
+
```typescript
|
|
263
|
+
list.on('click', function (event) {
|
|
264
|
+
const jjItem = JJHE.from(event.target as Node).closest('[data-item-id]')
|
|
265
|
+
if (!jjItem) return
|
|
266
|
+
this.addClass('handled')
|
|
267
|
+
jjItem.addClass('active')
|
|
268
|
+
})
|
|
269
|
+
```
|
|
270
|
+
|
|
156
271
|
## Custom Elements — Complete Pattern
|
|
157
272
|
|
|
158
273
|
Fetch template and style at **module scope** — loaded once, shared across all instances.
|
|
159
274
|
|
|
275
|
+
Guide defaults for component shape:
|
|
276
|
+
|
|
277
|
+
- Use shadow DOM for self-contained widgets and design-system components; use light DOM for sections that should inherit page styling and normal document flow.
|
|
278
|
+
- Prefer `'open'` shadow mode unless stricter encapsulation is a hard requirement.
|
|
279
|
+
- `attributeChangedCallback()` can run before `connectedCallback()` for parsed attributes, so setters and render paths must tolerate pre-mount state.
|
|
280
|
+
- Use `disconnectedCallback()` only to clean up external side effects like document listeners, timers, observers, or subscriptions; do not tear down the shadow root just because the element was detached.
|
|
281
|
+
|
|
160
282
|
```typescript
|
|
161
283
|
import { attr2prop, defineComponent, fetchStyle, fetchTemplate, JJHE } from 'jj'
|
|
162
284
|
|
|
@@ -169,12 +291,12 @@ export class MyCard extends HTMLElement {
|
|
|
169
291
|
|
|
170
292
|
#userName = ''
|
|
171
293
|
#count = 0
|
|
172
|
-
#
|
|
294
|
+
#jjShadow = null // JJSR wrapper; attached in constructor
|
|
173
295
|
#isInitialized = false
|
|
174
296
|
|
|
175
297
|
constructor() {
|
|
176
298
|
super()
|
|
177
|
-
this.#
|
|
299
|
+
this.#jjShadow = JJHE.from(this).setShadow('open').getShadow(true)
|
|
178
300
|
}
|
|
179
301
|
|
|
180
302
|
attributeChangedCallback(name, oldValue, newValue) {
|
|
@@ -200,16 +322,16 @@ export class MyCard extends HTMLElement {
|
|
|
200
322
|
|
|
201
323
|
async connectedCallback() {
|
|
202
324
|
if (!this.#isInitialized) {
|
|
203
|
-
this.#
|
|
325
|
+
this.#jjShadow.init(await templatePromise, await stylePromise)
|
|
204
326
|
this.#isInitialized = true
|
|
205
327
|
}
|
|
206
328
|
this.#render()
|
|
207
329
|
}
|
|
208
330
|
|
|
209
331
|
#render() {
|
|
210
|
-
if (!this.#
|
|
211
|
-
this.#
|
|
212
|
-
this.#
|
|
332
|
+
if (!this.#jjShadow) return // guard for attribute changes before mount
|
|
333
|
+
this.#jjShadow.find('[data-role="name"]')?.setText(this.#userName)
|
|
334
|
+
this.#jjShadow.find('[data-role="count"]')?.setText(String(this.#count))
|
|
213
335
|
}
|
|
214
336
|
}
|
|
215
337
|
|
|
@@ -219,6 +341,28 @@ await MyCard.defined
|
|
|
219
341
|
await Promise.all([MyCard.defined, OtherCard.defined])
|
|
220
342
|
```
|
|
221
343
|
|
|
344
|
+
Template defaults from the tutorial:
|
|
345
|
+
|
|
346
|
+
- Prefer fetched `.html` templates for large static markup.
|
|
347
|
+
- Prefer `<template>` elements for reusable DOM snippets already present in the page.
|
|
348
|
+
- Prefer `JJHE.tree()` or `JJHE.create()` when you need live wrapper references for later updates.
|
|
349
|
+
- Keep template promises at module scope; for lazy loading, initialize them inside `connectedCallback()` with an `if (!templatePromise)` guard.
|
|
350
|
+
- Use one stable wrapper per component: `#jjHost` with `JJHE.from(this)` for light DOM, or `#jjShadow` with `JJHE.from(this).setShadow(...).getShadow(true)` for shadow DOM.
|
|
351
|
+
- Initialize template content once, then update specific nodes with `find(...).setText(...)` or other targeted wrapper operations.
|
|
352
|
+
|
|
353
|
+
Guide defaults for attributes and queries:
|
|
354
|
+
|
|
355
|
+
- Always coerce attribute-backed values in setters because HTML attributes arrive as strings.
|
|
356
|
+
- Query inside shadow DOM from the `JJSR` wrapper, never from `document`.
|
|
357
|
+
- Use specific selectors like `button#submit` or `[data-role="title"]` so the selector carries intent.
|
|
358
|
+
|
|
359
|
+
State defaults from the tutorial:
|
|
360
|
+
|
|
361
|
+
- Prefer plain objects or classes for state and update the exact affected wrappers in event handlers or setters.
|
|
362
|
+
- Prefer targeted updates like `value.setText(String(state.count))` over rebuilding an entire subtree for a small change.
|
|
363
|
+
- Use getters/setters or small helper methods when they make state transitions clearer, not because JJ requires a framework-style abstraction.
|
|
364
|
+
- Reach for external state libraries only when the application actually needs cross-cutting coordination beyond local JS state.
|
|
365
|
+
|
|
222
366
|
`defineComponent()` returns `Promise<boolean>`:
|
|
223
367
|
|
|
224
368
|
- `false` — newly defined by this call
|
|
@@ -244,29 +388,36 @@ const card = h(
|
|
|
244
388
|
|
|
245
389
|
```typescript
|
|
246
390
|
// Clear children — internally uses replaceChildren()
|
|
247
|
-
|
|
391
|
+
jjEl.empty()
|
|
248
392
|
|
|
249
393
|
// Replace all children in one call (prefer over .empty().addChild())
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
394
|
+
jjEl.setChild(newChild)
|
|
395
|
+
jjEl.setChildren([childA, childB])
|
|
396
|
+
jjEl.setChildMap(items, (item) => JJHE.tree('li', null, item.label))
|
|
397
|
+
jjEl.setTemplate(templateElement)
|
|
254
398
|
|
|
255
399
|
// Append
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
400
|
+
jjEl.addChild(child)
|
|
401
|
+
jjEl.addChildMap(items, (item) => JJHE.tree('li', null, item.label))
|
|
402
|
+
jjEl.addTemplate(await templatePromise) // clones before appending
|
|
259
403
|
```
|
|
260
404
|
|
|
261
405
|
`addChild` / `preChild` / `setChild` and map variants ignore `null`/`undefined`; all other non-node values are coerced to Text nodes.
|
|
262
406
|
|
|
407
|
+
Guide defaults for template and fragment usage:
|
|
408
|
+
|
|
409
|
+
- `addTemplate()` and `setTemplate()` always clone the input before appending; reuse the same template value safely.
|
|
410
|
+
- Prefer `setTemplate()` over `empty().addTemplate()` when replacing all content.
|
|
411
|
+
- Prefer `addChildMap()` or `setChildMap()` over manually building a fragment when rendering arrays.
|
|
412
|
+
- Use `JJDF.create()` when you need to assemble multiple sibling nodes before one insertion.
|
|
413
|
+
|
|
263
414
|
## Node Traversal
|
|
264
415
|
|
|
265
416
|
```typescript
|
|
266
|
-
const parent =
|
|
267
|
-
const children =
|
|
268
|
-
|
|
269
|
-
const ancestor =
|
|
417
|
+
const parent = jjEl.getParent() // wrapped parent or null (detached)
|
|
418
|
+
const children = jjEl.getChildren() // wrapped child array (always an array)
|
|
419
|
+
jjEl.rm() // detach from parent (no-op if already detached)
|
|
420
|
+
const ancestor = jjEl.closest('[data-section]') // null if not found
|
|
270
421
|
```
|
|
271
422
|
|
|
272
423
|
## Resource Loaders
|
|
@@ -299,6 +450,12 @@ document.adoptedStyleSheets = [sheet]
|
|
|
299
450
|
const fragment = await fetchTemplate(import.meta.resolve('./dialog.html'))
|
|
300
451
|
```
|
|
301
452
|
|
|
453
|
+
Guide defaults for browser-native loading hints:
|
|
454
|
+
|
|
455
|
+
- Use native `<link>` hints built with `JJHE.tree` and appended to `head` for `preload`, `prefetch`, and `modulepreload`.
|
|
456
|
+
- Keep the `as` value explicit for `preload` instead of inferring it from file extensions.
|
|
457
|
+
- Use `preload` for current-page needs, `prefetch` for probable future navigation, and `modulepreload` for module graphs you want fetched early.
|
|
458
|
+
|
|
302
459
|
## String Casing
|
|
303
460
|
|
|
304
461
|
String case-conversion helpers are internal implementation details.
|
|
@@ -308,10 +465,11 @@ Use higher-level public APIs like `attr2prop` and `defineComponent` instead of i
|
|
|
308
465
|
|
|
309
466
|
1. **`.ts` extension in imports** — TypeScript source must use `.js` (`import { X } from './X.js'`).
|
|
310
467
|
2. **`JJHE.create('svg')`** — throws; use `JJSE.create('svg')`.
|
|
311
|
-
3. **`
|
|
468
|
+
3. **`jjEl.setHTML(html)` without `true`** — throws when html is non-empty.
|
|
312
469
|
4. **Fetching template/style inside `connectedCallback`** — fetch at module scope so the network request is shared.
|
|
313
470
|
5. **Not awaiting `Element.defined`** — markup may be parsed before the element is defined, causing flaky upgrades.
|
|
314
471
|
6. **Breaking the chain with `.ref` unnecessarily** — use wrapper methods first; reach for `.ref` only when no wrapper method exists.
|
|
472
|
+
7. **Using `.ref.value` for common value updates** — prefer `getValue()` / `setValue(...)` for wrapper-level reads/writes.
|
|
315
473
|
|
|
316
474
|
## Pitfall Prevention Rules (Component Work)
|
|
317
475
|
|
|
@@ -62,15 +62,16 @@ button {
|
|
|
62
62
|
}
|
|
63
63
|
```
|
|
64
64
|
|
|
65
|
-
## Loading
|
|
65
|
+
## Loading css files
|
|
66
66
|
|
|
67
67
|
```js
|
|
68
|
-
import { JJHE, fetchStyle } from 'jj'
|
|
68
|
+
import { JJD, JJHE, fetchStyle } from 'jj'
|
|
69
69
|
|
|
70
70
|
const h = JJHE.tree
|
|
71
|
+
const jjDoc = JJD.from(document)
|
|
71
72
|
|
|
72
73
|
// Hint browser to start loading early
|
|
73
|
-
|
|
74
|
+
jjDoc.find('head', true).addChild(
|
|
74
75
|
h('link', {
|
|
75
76
|
href: import.meta.resolve('./theme.css'),
|
|
76
77
|
rel: 'preload',
|
|
@@ -3,15 +3,15 @@
|
|
|
3
3
|
## Native event listeners
|
|
4
4
|
|
|
5
5
|
```js
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
jjEl.on('click', handler) // addEventListener
|
|
7
|
+
jjEl.off('click', handler) // removeEventListener (same function reference)
|
|
8
|
+
jjEl.triggerEvent('click') // creates and dispatches Event('click')
|
|
9
9
|
```
|
|
10
10
|
|
|
11
11
|
The handler `this` inside `.on()` is bound to the JJ wrapper instance. Use `this.ref` to access the native element:
|
|
12
12
|
|
|
13
13
|
```js
|
|
14
|
-
|
|
14
|
+
jjBtn.on('click', function () {
|
|
15
15
|
// this → the JJHE wrapper
|
|
16
16
|
// this.ref → the HTMLButtonElement
|
|
17
17
|
console.log(this.ref.textContent)
|
|
@@ -54,10 +54,16 @@ new CustomEvent('internal-ready', { bubbles: false, composed: false })
|
|
|
54
54
|
## Event delegation
|
|
55
55
|
|
|
56
56
|
```js
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
57
|
+
import { JJD } from 'jj'
|
|
58
|
+
|
|
59
|
+
const jjDoc = JJD.from(document)
|
|
60
|
+
const jjList = jjDoc.find('#list', true)
|
|
61
|
+
jjList.on('click', (e) => {
|
|
62
|
+
if (!(e.target instanceof Element)) {
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
const itemEl = e.target.closest('[data-id]')
|
|
66
|
+
if (itemEl) handleClick(itemEl.dataset.id)
|
|
61
67
|
})
|
|
62
68
|
```
|
|
63
69
|
|
|
@@ -65,7 +71,7 @@ list.on('click', (e) => {
|
|
|
65
71
|
|
|
66
72
|
```js
|
|
67
73
|
const ctrl = new AbortController()
|
|
68
|
-
|
|
74
|
+
jjEl.ref.addEventListener('click', handler, { signal: ctrl.signal })
|
|
69
75
|
// later:
|
|
70
76
|
ctrl.abort() // removes listener
|
|
71
77
|
```
|
|
@@ -7,8 +7,8 @@ jQuery provides chainable selection and DOM operations. JJ is similar in API fee
|
|
|
7
7
|
| jQuery | JJ equivalent |
|
|
8
8
|
| ------------------------------ | --------------------------------------------------------------------------------- |
|
|
9
9
|
| `$('#id')` | `JJD.from(document).find('#id')` |
|
|
10
|
-
| `$('.cls')` (first match) | `
|
|
11
|
-
| `$('.cls')` (all matches) | `
|
|
10
|
+
| `$('.cls')` (first match) | `jjDoc.find('.cls')` |
|
|
11
|
+
| `$('.cls')` (all matches) | `jjDoc.findAll('.cls')` |
|
|
12
12
|
| `.addClass()` / `.removeClass` | `.addClass()` / `.rmClass()` |
|
|
13
13
|
| `.swClass(cls, bool?)` | `.swClass(cls, force?)` — explicit when force is provided, auto-flip when omitted |
|
|
14
14
|
| `.attr(name, val)` (write) | `.setAttr(name, val)` |
|
|
@@ -36,8 +36,8 @@ $('.card').addClass('active').css('color', 'red')
|
|
|
36
36
|
JJ:
|
|
37
37
|
|
|
38
38
|
```js
|
|
39
|
-
const
|
|
40
|
-
|
|
39
|
+
const jjDoc = JJD.from(document)
|
|
40
|
+
jjDoc.findAll('.card').forEach((jjCard) => jjCard.addClass('active').setStyle('color', 'red'))
|
|
41
41
|
```
|
|
42
42
|
|
|
43
43
|
## HTML write safety
|
|
@@ -51,8 +51,8 @@ $('#msg').html(userInput) // XSS risk
|
|
|
51
51
|
JJ requires explicit acknowledgement:
|
|
52
52
|
|
|
53
53
|
```js
|
|
54
|
-
|
|
55
|
-
|
|
54
|
+
jjDoc.find('#msg').setHTML(trustedMarkup, true) // must pass true
|
|
55
|
+
jjDoc.find('#msg').setText(userInput) // safe for user content
|
|
56
56
|
```
|
|
57
57
|
|
|
58
58
|
## Event delegation
|
|
@@ -66,7 +66,10 @@ $(document).on('click', '.item', handler)
|
|
|
66
66
|
JJ — use native event delegation via `matches`:
|
|
67
67
|
|
|
68
68
|
```js
|
|
69
|
-
|
|
69
|
+
jjDoc.on('click', (e) => {
|
|
70
|
+
if (!(e.target instanceof Element)) {
|
|
71
|
+
return
|
|
72
|
+
}
|
|
70
73
|
if (e.target.matches('.item')) handler(e)
|
|
71
74
|
})
|
|
72
75
|
```
|
|
@@ -13,7 +13,7 @@ Lit adds reactive properties, template literals, and scoped CSS on top of native
|
|
|
13
13
|
| `html\`<div>\`` | `JJHE.tree('div', …)` or `JJHE.create('div')` |
|
|
14
14
|
| `@event="${handler}"` | `.on('event', handler)` in `connectedCallback` |
|
|
15
15
|
| `updated()` hook | Call `#render()` from setters that change visible state |
|
|
16
|
-
| `this.renderRoot.query…` | `this.#
|
|
16
|
+
| `this.renderRoot.query…` | `this.#jjShadow.find(selector)` |
|
|
17
17
|
|
|
18
18
|
## Component example
|
|
19
19
|
|
|
@@ -46,12 +46,12 @@ export class MyCounter extends HTMLElement {
|
|
|
46
46
|
static defined = defineComponent('my-counter', MyCounter)
|
|
47
47
|
|
|
48
48
|
#count = 0
|
|
49
|
-
#
|
|
49
|
+
#jjShadow = null
|
|
50
50
|
#isInitialized = false
|
|
51
51
|
|
|
52
52
|
constructor() {
|
|
53
53
|
super()
|
|
54
|
-
this.#
|
|
54
|
+
this.#jjShadow = JJHE.from(this).setShadow('open').getShadow(true)
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
attributeChangedCallback(name, oldValue, newValue) {
|
|
@@ -67,7 +67,7 @@ export class MyCounter extends HTMLElement {
|
|
|
67
67
|
|
|
68
68
|
async connectedCallback() {
|
|
69
69
|
if (!this.#isInitialized) {
|
|
70
|
-
this.#
|
|
70
|
+
this.#jjShadow
|
|
71
71
|
.init('<button id="btn">0</button>', await stylePromise)
|
|
72
72
|
.find('#btn', true)
|
|
73
73
|
.on('click', () => {
|
|
@@ -79,7 +79,7 @@ export class MyCounter extends HTMLElement {
|
|
|
79
79
|
}
|
|
80
80
|
|
|
81
81
|
#render() {
|
|
82
|
-
this.#
|
|
82
|
+
this.#jjShadow?.find('#btn')?.setText(String(this.#count))
|
|
83
83
|
}
|
|
84
84
|
}
|
|
85
85
|
```
|
|
@@ -5,16 +5,16 @@
|
|
|
5
5
|
Always start from a wrapped container — most commonly the document or a shadow root:
|
|
6
6
|
|
|
7
7
|
```js
|
|
8
|
-
const
|
|
9
|
-
const
|
|
8
|
+
const jjDoc = JJD.from(document)
|
|
9
|
+
const jjShadow = this.#jjShadow // JJSR inside a custom element
|
|
10
10
|
```
|
|
11
11
|
|
|
12
12
|
## find — first match
|
|
13
13
|
|
|
14
14
|
```js
|
|
15
|
-
const
|
|
16
|
-
const
|
|
17
|
-
const
|
|
15
|
+
const jjCard = jjDoc.find('.card') // null when absent
|
|
16
|
+
const jjApp = jjDoc.find('#app', true) // throws ReferenceError when absent
|
|
17
|
+
const jjBtn = jjShadow.find('#submit') // scoped to shadow root
|
|
18
18
|
```
|
|
19
19
|
|
|
20
20
|
Pass `true` as the second argument when the element is required. This produces a clearer error than a null-access crash later.
|
|
@@ -22,9 +22,9 @@ Pass `true` as the second argument when the element is required. This produces a
|
|
|
22
22
|
You can use it in combination with a more specific query to simultaneously assert your expectations. For example, if you want a reference to a button with the id `submit`, you could write:
|
|
23
23
|
|
|
24
24
|
```js
|
|
25
|
-
const
|
|
25
|
+
const jjSubmitBtn = jjDoc.find('#submit-btn', true)
|
|
26
26
|
|
|
27
|
-
if (!(
|
|
27
|
+
if (!(jjSubmitBtn.ref instanceof HTMLButtonElement)) {
|
|
28
28
|
throw new Error('Expected #submit-btn to be an HTMLButtonElement.')
|
|
29
29
|
}
|
|
30
30
|
```
|
|
@@ -34,7 +34,7 @@ But a shorter and more expressive way to write this is:
|
|
|
34
34
|
```js
|
|
35
35
|
// This will throw an exception if an element with id is not found
|
|
36
36
|
// OR if it's found but not a button
|
|
37
|
-
const
|
|
37
|
+
const jjSubmitBtn = jjDoc.find('button#submit-btn', true)
|
|
38
38
|
```
|
|
39
39
|
|
|
40
40
|
This helps catch errors early and narrow down troubleshooting.
|
|
@@ -43,19 +43,19 @@ When `find` returns a wrapper, keep the wrapper unless you need a native API tha
|
|
|
43
43
|
|
|
44
44
|
```js
|
|
45
45
|
// ✅ keep wrapper value
|
|
46
|
-
const jjSubmitBtn =
|
|
46
|
+
const jjSubmitBtn = jjDoc.find('button#submit-btn', true)
|
|
47
47
|
jjSubmitBtn.on('click', onSubmit)
|
|
48
48
|
|
|
49
49
|
// ❌ avoid unwrap + re-wrap noise
|
|
50
|
-
const submitRef =
|
|
50
|
+
const submitRef = jjDoc.find('button#submit-btn', true).ref
|
|
51
51
|
const jjSubmitBtnAgain = JJHE.from(submitRef)
|
|
52
52
|
```
|
|
53
53
|
|
|
54
54
|
## findAll — all matches
|
|
55
55
|
|
|
56
56
|
```js
|
|
57
|
-
const
|
|
58
|
-
|
|
57
|
+
const jjItems = jjDoc.findAll('li.item') // always an array (may be empty)
|
|
58
|
+
jjItems.forEach((jjItem) => jjItem.addClass('loaded'))
|
|
59
59
|
```
|
|
60
60
|
|
|
61
61
|
## closest — ancestor lookup
|
|
@@ -63,16 +63,19 @@ items.forEach((item) => item.addClass('loaded'))
|
|
|
63
63
|
Use `.closest()` on element wrappers for event delegation or tree navigation:
|
|
64
64
|
|
|
65
65
|
```js
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
66
|
+
jjDoc.on('click', (e) => {
|
|
67
|
+
if (!(e.target instanceof Node)) {
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
const jjItem = JJHE.from(e.target).closest('[data-item-id]')
|
|
71
|
+
if (jjItem) handleItemClick(jjItem.getAttr('data-item-id'))
|
|
69
72
|
})
|
|
70
73
|
```
|
|
71
74
|
|
|
72
75
|
## fromId — direct ID lookup
|
|
73
76
|
|
|
74
77
|
```js
|
|
75
|
-
const
|
|
78
|
+
const jjBtn = JJHE.fromId('submit-btn') // typed as JJHE<HTMLButtonElement>
|
|
76
79
|
```
|
|
77
80
|
|
|
78
81
|
## When to use .ref for queries
|
|
@@ -31,12 +31,12 @@ JJ:
|
|
|
31
31
|
import { JJHE } from 'jj'
|
|
32
32
|
|
|
33
33
|
let count = 0
|
|
34
|
-
const
|
|
35
|
-
|
|
34
|
+
const jjBtn = JJHE.create('button').setText('0')
|
|
35
|
+
jjBtn.on('click', () => {
|
|
36
36
|
count++
|
|
37
|
-
|
|
37
|
+
jjBtn.setText(String(count))
|
|
38
38
|
})
|
|
39
|
-
document.body.appendChild(
|
|
39
|
+
document.body.appendChild(jjBtn.ref)
|
|
40
40
|
```
|
|
41
41
|
|
|
42
42
|
## Component props → observed attributes
|
|
@@ -73,7 +73,7 @@ JJ — child dispatches, parent listens:
|
|
|
73
73
|
JJHE.from(this).triggerCustomEvent('todo-toggle', { id: this.#id })
|
|
74
74
|
|
|
75
75
|
// parent
|
|
76
|
-
|
|
76
|
+
jjContainer.on('todo-toggle', (e) => handleToggle(e.detail.id))
|
|
77
77
|
```
|
|
78
78
|
|
|
79
79
|
## Browser references
|
|
@@ -32,9 +32,9 @@ JJ:
|
|
|
32
32
|
import { JJHE } from 'jj'
|
|
33
33
|
|
|
34
34
|
let count = 0
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
document.body.appendChild(
|
|
35
|
+
const jjBtn = JJHE.create('button').setText('0')
|
|
36
|
+
jjBtn.on('click', () => jjBtn.setText(String(++count)))
|
|
37
|
+
document.body.appendChild(jjBtn.ref)
|
|
38
38
|
```
|
|
39
39
|
|
|
40
40
|
## Outbound events
|
|
@@ -33,9 +33,9 @@ import { JJHE } from '../src/index.js'
|
|
|
33
33
|
describe('JJHE', () => {
|
|
34
34
|
describe('static create()', () => {
|
|
35
35
|
it('creates element from tag name', () => {
|
|
36
|
-
const
|
|
37
|
-
assert.ok(
|
|
38
|
-
assert.strictEqual(
|
|
36
|
+
const jjDiv = JJHE.create('div')
|
|
37
|
+
assert.ok(jjDiv instanceof JJHE)
|
|
38
|
+
assert.strictEqual(jjDiv.ref.tagName, 'DIV')
|
|
39
39
|
})
|
|
40
40
|
|
|
41
41
|
it('throws TypeError for non-string tagName', () => {
|
|
@@ -68,12 +68,12 @@ it('renders title in shadow', async () => {
|
|
|
68
68
|
|
|
69
69
|
```js
|
|
70
70
|
it('dispatches todo-toggle with detail', () => {
|
|
71
|
-
const
|
|
71
|
+
const jjEl = JJHE.create('div')
|
|
72
72
|
let captured = null
|
|
73
|
-
|
|
73
|
+
jjEl.on('todo-toggle', (e) => {
|
|
74
74
|
captured = e.detail
|
|
75
75
|
})
|
|
76
|
-
|
|
76
|
+
jjEl.triggerCustomEvent('todo-toggle', { id: 1, done: true })
|
|
77
77
|
assert.deepStrictEqual(captured, { id: 1, done: true })
|
|
78
78
|
})
|
|
79
79
|
```
|
|
@@ -30,12 +30,12 @@ JJ:
|
|
|
30
30
|
import { JJHE } from 'jj'
|
|
31
31
|
|
|
32
32
|
let message = ''
|
|
33
|
-
const
|
|
34
|
-
const
|
|
33
|
+
const jjMessage = JJHE.create('p').setText('')
|
|
34
|
+
const jjInput = JJHE.create('input')
|
|
35
35
|
.setAttr('type', 'text')
|
|
36
|
-
.on('input', (
|
|
37
|
-
message =
|
|
38
|
-
|
|
36
|
+
.on('input', () => {
|
|
37
|
+
message = jjInput.getValue()
|
|
38
|
+
jjMessage.setText(message)
|
|
39
39
|
})
|
|
40
40
|
```
|
|
41
41
|
|
|
@@ -62,7 +62,7 @@ set count(v) {
|
|
|
62
62
|
## v-for replacement
|
|
63
63
|
|
|
64
64
|
```js
|
|
65
|
-
|
|
65
|
+
jjList.addChildMap(items, (item) => JJHE.tree('li', { class: 'item' }, item.label))
|
|
66
66
|
```
|
|
67
67
|
|
|
68
68
|
## Browser references
|
|
@@ -37,10 +37,16 @@ use `defineComponent()` which also calls `customElements.whenDefined()`.
|
|
|
37
37
|
const templatePromise = fetchTemplate(import.meta.resolve('./my-component.html'))
|
|
38
38
|
const stylePromise = fetchStyle(import.meta.resolve('./my-component.css'))
|
|
39
39
|
|
|
40
|
+
constructor() {
|
|
41
|
+
super()
|
|
42
|
+
this.#jjHost = JJHE.from(this).setShadow('open')
|
|
43
|
+
this.#jjShadow = this.#jjHost.getShadow(true)
|
|
44
|
+
}
|
|
45
|
+
|
|
40
46
|
async connectedCallback() {
|
|
41
|
-
// setShadow(mode) in the constructor, then
|
|
42
|
-
|
|
43
|
-
this.#
|
|
47
|
+
// setShadow(mode) in the constructor, then initialize once here
|
|
48
|
+
this.#jjHost.initShadow(await templatePromise, await stylePromise)
|
|
49
|
+
this.#jjShadow.find('#btn', true).on('click', this.#handleClick)
|
|
44
50
|
this.#render()
|
|
45
51
|
}
|
|
46
52
|
```
|
|
@@ -63,8 +69,8 @@ attributeChangedCallback(name, oldValue, newValue) {
|
|
|
63
69
|
// attr2prop fires attributeChangedCallback BEFORE connectedCallback on initial parse.
|
|
64
70
|
// Guard renders until shadow is ready:
|
|
65
71
|
#render() {
|
|
66
|
-
if (!this.#
|
|
67
|
-
this.#
|
|
72
|
+
if (!this.#jjShadow) return
|
|
73
|
+
this.#jjShadow.find('[data-role="name"]')?.setText(this.#userName)
|
|
68
74
|
}
|
|
69
75
|
```
|
|
70
76
|
|
|
@@ -74,8 +80,8 @@ Skip `setShadow` and update children directly when page-level styling should app
|
|
|
74
80
|
|
|
75
81
|
```js
|
|
76
82
|
async connectedCallback() {
|
|
77
|
-
this.#
|
|
78
|
-
this.#
|
|
83
|
+
this.#jjHost = JJHE.from(this)
|
|
84
|
+
this.#jjHost.setTemplate(await templatePromise)
|
|
79
85
|
this.#render()
|
|
80
86
|
}
|
|
81
87
|
```
|