senren-ui 0.1.6 → 0.2.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +104 -0
- data/CONTRIBUTING.md +40 -3
- data/README.md +110 -33
- data/Rakefile +14 -1
- data/docs/components.md +10 -10
- data/docs/hot_reload.md +103 -0
- data/docs/performance_testing.md +7 -3
- data/lib/generators/senren/component/templates/controller.js.tt +7 -4
- data/lib/generators/senren/install/install_generator.rb +74 -0
- data/lib/generators/senren/install/templates/base_component.rb.tt +52 -4
- data/lib/generators/senren/install/templates/conventions.md.tt +16 -1
- data/lib/senren/rails/agent_rules_writer.rb +62 -19
- data/lib/senren/rails/asset_path_guard.rb +128 -0
- data/lib/senren/rails/base_component_patch.rb +64 -0
- data/lib/senren/rails/component_copier.rb +101 -50
- data/lib/senren/rails/component_installer.rb +26 -0
- data/lib/senren/rails/doctor.rb +7 -4
- data/lib/senren/rails/engine.rb +23 -0
- data/lib/senren/rails/host_paths.rb +11 -2
- data/lib/senren/rails/marker_block.rb +81 -0
- data/lib/senren/rails/registry.rb +10 -4
- data/lib/senren/rails/safe_write.rb +169 -0
- data/lib/senren/rails/skill_writer.rb +47 -11
- data/lib/senren/rails/version.rb +1 -1
- data/lib/senren/rails.rb +1 -1
- data/lib/senren-ui.rb +15 -0
- data/lib/tasks/senren.rake +43 -17
- data/registry/components.yml +45 -0
- data/registry/recipes.yml +12 -0
- data/templates/components/alert_dialog/alert_dialog_component.rb +1 -1
- data/templates/components/api_key_field/api_key_field_component.html.erb +1 -1
- data/templates/components/aspect_ratio/aspect_ratio_component.rb +7 -0
- data/templates/components/avatar/avatar_component.rb +8 -1
- data/templates/components/cart/cart_component.html.erb +61 -0
- data/templates/components/cart/cart_component.rb +71 -0
- data/templates/components/checkbox/checkbox_component.rb +1 -1
- data/templates/components/clipboard/clipboard_component.html.erb +1 -1
- data/templates/components/command/command_component.rb +1 -1
- data/templates/components/date_picker/date_picker_component.html.erb +1 -1
- data/templates/components/dialog/dialog_component.rb +1 -1
- data/templates/components/form/form_component.rb +9 -1
- data/templates/components/invite_member_dialog/invite_member_dialog_component.rb +1 -1
- data/templates/components/product_card/product_card_component.html.erb +38 -0
- data/templates/components/product_card/product_card_component.rb +49 -0
- data/templates/components/rich_text_editor_lite/rich_text_editor_lite_component.html.erb +1 -1
- data/templates/components/rich_text_editor_lite/rich_text_editor_lite_component.rb +1 -1
- data/templates/components/separator/separator_component.rb +7 -0
- data/templates/components/sheet/sheet_component.rb +1 -1
- data/templates/components/tooltip/tooltip_component.rb +2 -2
- data/templates/components/typography/typography_component.rb +7 -0
- data/templates/controllers/accordion_controller.js +1 -1
- data/templates/controllers/alert_dialog_controller.js +31 -7
- data/templates/controllers/cart_controller.js +83 -0
- data/templates/controllers/clipboard_controller.js +12 -1
- data/templates/controllers/command_controller.js +3 -4
- data/templates/controllers/context_menu_controller.js +38 -11
- data/templates/controllers/data_table_controller.js +8 -3
- data/templates/controllers/dialog_controller.js +47 -21
- data/templates/controllers/dropdown_menu_controller.js +40 -27
- data/templates/controllers/hover_card_controller.js +8 -0
- data/templates/controllers/invite_member_dialog_controller.js +6 -0
- data/templates/controllers/masked_input_controller.js +8 -1
- data/templates/controllers/popover_controller.js +25 -10
- data/templates/controllers/rich_text_editor_lite_controller.js +165 -28
- data/templates/controllers/sheet_controller.js +41 -11
- metadata +13 -17
- data/lib/senren/rails/installer.rb +0 -85
|
@@ -2,8 +2,13 @@ import { Controller } from "@hotwired/stimulus"
|
|
|
2
2
|
|
|
3
3
|
// senren--popover
|
|
4
4
|
// Toggles a small panel; closes on outside click and Escape.
|
|
5
|
+
//
|
|
6
|
+
// State lives in `openValue`. Actions assign it; openValueChanged performs
|
|
7
|
+
// every DOM change and owns the document listeners, so the open state is
|
|
8
|
+
// server-renderable and survives a Turbo morph.
|
|
5
9
|
export default class extends Controller {
|
|
6
10
|
static targets = ["trigger", "panel"]
|
|
11
|
+
static values = { open: Boolean }
|
|
7
12
|
|
|
8
13
|
connect() {
|
|
9
14
|
this._onDocClick = this._onDocClick.bind(this)
|
|
@@ -11,25 +16,35 @@ export default class extends Controller {
|
|
|
11
16
|
}
|
|
12
17
|
|
|
13
18
|
disconnect() {
|
|
14
|
-
|
|
15
|
-
document.removeEventListener("keydown", this._onKey)
|
|
19
|
+
this._stopListening()
|
|
16
20
|
}
|
|
17
21
|
|
|
18
22
|
toggle(event) {
|
|
19
23
|
event?.preventDefault()
|
|
20
|
-
this.
|
|
24
|
+
this.openValue = !this.openValue
|
|
21
25
|
}
|
|
22
26
|
|
|
23
27
|
close() {
|
|
24
|
-
this.
|
|
25
|
-
|
|
26
|
-
|
|
28
|
+
this.openValue = false
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
openValueChanged(isOpen) {
|
|
32
|
+
if (this.hasPanelTarget) this.panelTarget.hidden = !isOpen
|
|
33
|
+
if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", isOpen ? "true" : "false")
|
|
34
|
+
|
|
35
|
+
if (isOpen) {
|
|
36
|
+
document.addEventListener("click", this._onDocClick)
|
|
37
|
+
document.addEventListener("keydown", this._onKey)
|
|
38
|
+
this.dispatch("opened")
|
|
39
|
+
return
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
this._stopListening()
|
|
27
43
|
}
|
|
28
44
|
|
|
29
|
-
|
|
30
|
-
this.
|
|
31
|
-
document.
|
|
32
|
-
document.addEventListener("keydown", this._onKey)
|
|
45
|
+
_stopListening() {
|
|
46
|
+
document.removeEventListener("click", this._onDocClick)
|
|
47
|
+
document.removeEventListener("keydown", this._onKey)
|
|
33
48
|
}
|
|
34
49
|
|
|
35
50
|
_onDocClick(event) {
|
|
@@ -2,6 +2,16 @@ import { Controller } from "@hotwired/stimulus"
|
|
|
2
2
|
|
|
3
3
|
const ALLOWED_LINK_PROTOCOLS = new Set(["http:", "https:", "mailto:", "tel:"])
|
|
4
4
|
|
|
5
|
+
// Mirrors the server-side sanitize() allowlist in
|
|
6
|
+
// rich_text_editor_lite_component.html.erb, so both ends agree on what the
|
|
7
|
+
// stored value may contain.
|
|
8
|
+
const ALLOWED_PASTE_TAGS = new Set([
|
|
9
|
+
"P", "BR", "STRONG", "B", "EM", "I", "A", "UL", "OL", "LI", "H1", "H2", "H3"
|
|
10
|
+
])
|
|
11
|
+
const ALLOWED_PASTE_ATTRIBUTES = new Set(["href", "rel", "target", "data-align"])
|
|
12
|
+
// Dropped whole rather than unwrapped: their text content is code, not prose.
|
|
13
|
+
const DROPPED_PASTE_TAGS = new Set(["SCRIPT", "STYLE", "NOSCRIPT", "TEMPLATE", "IFRAME", "OBJECT", "EMBED"])
|
|
14
|
+
|
|
5
15
|
// senren--rich-text-editor-lite
|
|
6
16
|
// Local UI: tiny contenteditable toolbar synced to a hidden textarea.
|
|
7
17
|
export default class extends Controller {
|
|
@@ -17,7 +27,7 @@ export default class extends Controller {
|
|
|
17
27
|
this.sync()
|
|
18
28
|
this.rememberSelection()
|
|
19
29
|
this.updateToolbar()
|
|
20
|
-
this.debug("connect", this.snapshot())
|
|
30
|
+
this.debug("connect", () => (this.snapshot()))
|
|
21
31
|
}
|
|
22
32
|
|
|
23
33
|
keepSelection(event) {
|
|
@@ -33,7 +43,12 @@ export default class extends Controller {
|
|
|
33
43
|
if (!event.metaKey && !event.ctrlKey) return
|
|
34
44
|
|
|
35
45
|
event.preventDefault()
|
|
36
|
-
|
|
46
|
+
// Anchors can arrive from a paste, so gate the protocol here too rather
|
|
47
|
+
// than trusting the browser to refuse javascript:/data: in window.open.
|
|
48
|
+
const href = this.normalizeHref(link.getAttribute("href"))
|
|
49
|
+
if (!href) return
|
|
50
|
+
|
|
51
|
+
window.open(href, "_blank", "noopener,noreferrer")
|
|
37
52
|
}
|
|
38
53
|
|
|
39
54
|
format(event) {
|
|
@@ -41,11 +56,11 @@ export default class extends Controller {
|
|
|
41
56
|
const command = event.currentTarget.dataset.command
|
|
42
57
|
if (!command) return
|
|
43
58
|
|
|
44
|
-
this.debug("format:start", { command, before: this.snapshot() })
|
|
59
|
+
this.debug("format:start", () => ({ command, before: this.snapshot() }))
|
|
45
60
|
this.restoreSelection()
|
|
46
61
|
if (command === "createLink") {
|
|
47
62
|
const url = window.prompt("Paste a URL")
|
|
48
|
-
this.debug("createLink:prompt", { url, afterPrompt: this.snapshot() })
|
|
63
|
+
this.debug("createLink:prompt", () => ({ url, afterPrompt: this.snapshot() }))
|
|
49
64
|
if (!url) return
|
|
50
65
|
this.restoreSelection()
|
|
51
66
|
this.insertLink(url)
|
|
@@ -65,7 +80,7 @@ export default class extends Controller {
|
|
|
65
80
|
this.sync()
|
|
66
81
|
this.updateToolbar()
|
|
67
82
|
this.rememberSelection()
|
|
68
|
-
this.debug("format:done", { command, after: this.snapshot() })
|
|
83
|
+
this.debug("format:done", () => ({ command, after: this.snapshot() }))
|
|
69
84
|
}
|
|
70
85
|
|
|
71
86
|
sync() {
|
|
@@ -77,6 +92,96 @@ export default class extends Controller {
|
|
|
77
92
|
requestAnimationFrame(() => this.sync())
|
|
78
93
|
}
|
|
79
94
|
|
|
95
|
+
// Handles both paste and drop. The browser's own contenteditable paste path
|
|
96
|
+
// keeps on* handler attributes, so pasted markup must be rebuilt against an
|
|
97
|
+
// allowlist before it reaches the document.
|
|
98
|
+
paste(event) {
|
|
99
|
+
const transfer = event.clipboardData || event.dataTransfer
|
|
100
|
+
if (!transfer) return
|
|
101
|
+
|
|
102
|
+
event.preventDefault()
|
|
103
|
+
const html = transfer.getData("text/html")
|
|
104
|
+
const text = transfer.getData("text/plain")
|
|
105
|
+
|
|
106
|
+
if (html) {
|
|
107
|
+
this.insertSanitized(this.sanitizeToFragment(html))
|
|
108
|
+
} else if (text) {
|
|
109
|
+
document.execCommand("insertText", false, text)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
this.sync()
|
|
113
|
+
this.rememberSelection()
|
|
114
|
+
this.updateToolbar()
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
insertSanitized(fragment) {
|
|
118
|
+
if (fragment.childNodes.length === 0) return
|
|
119
|
+
|
|
120
|
+
const range = this.activeRange()
|
|
121
|
+
if (!range) {
|
|
122
|
+
this.editorTarget.appendChild(fragment)
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const last = fragment.lastChild
|
|
127
|
+
range.deleteContents()
|
|
128
|
+
range.insertNode(fragment)
|
|
129
|
+
if (last) {
|
|
130
|
+
range.setStartAfter(last)
|
|
131
|
+
range.collapse(true)
|
|
132
|
+
const selection = window.getSelection()
|
|
133
|
+
selection.removeAllRanges()
|
|
134
|
+
selection.addRange(range)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// DOMParser builds an inert document: no scripts run and no resources load,
|
|
139
|
+
// so onerror/onload never fire while we walk the tree.
|
|
140
|
+
sanitizeToFragment(html) {
|
|
141
|
+
const parsed = new DOMParser().parseFromString(html, "text/html")
|
|
142
|
+
return this.sanitizeChildren(parsed.body)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
sanitizeChildren(node) {
|
|
146
|
+
const fragment = document.createDocumentFragment()
|
|
147
|
+
Array.from(node.childNodes).forEach((child) => {
|
|
148
|
+
const clean = this.sanitizeNode(child)
|
|
149
|
+
if (clean) fragment.appendChild(clean)
|
|
150
|
+
})
|
|
151
|
+
return fragment
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
sanitizeNode(node) {
|
|
155
|
+
if (node.nodeType === Node.TEXT_NODE) return document.createTextNode(node.nodeValue)
|
|
156
|
+
if (node.nodeType !== Node.ELEMENT_NODE) return null
|
|
157
|
+
|
|
158
|
+
const tag = node.tagName.toUpperCase()
|
|
159
|
+
if (DROPPED_PASTE_TAGS.has(tag)) return null
|
|
160
|
+
// Other disallowed elements are unwrapped, not dropped: keep their text.
|
|
161
|
+
if (!ALLOWED_PASTE_TAGS.has(tag)) return this.sanitizeChildren(node)
|
|
162
|
+
|
|
163
|
+
const element = document.createElement(tag.toLowerCase())
|
|
164
|
+
Array.from(node.attributes).forEach((attr) => {
|
|
165
|
+
const name = attr.name.toLowerCase()
|
|
166
|
+
if (!ALLOWED_PASTE_ATTRIBUTES.has(name)) return
|
|
167
|
+
|
|
168
|
+
if (name === "href") {
|
|
169
|
+
const href = this.normalizeHref(attr.value)
|
|
170
|
+
if (href) element.setAttribute("href", href)
|
|
171
|
+
return
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
element.setAttribute(name, attr.value)
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
if (tag === "A" && element.hasAttribute("target")) {
|
|
178
|
+
element.setAttribute("rel", "noopener noreferrer")
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
element.appendChild(this.sanitizeChildren(node))
|
|
182
|
+
return element
|
|
183
|
+
}
|
|
184
|
+
|
|
80
185
|
updateToolbar() {
|
|
81
186
|
this.buttonTargets.forEach((button) => {
|
|
82
187
|
const command = button.dataset.command
|
|
@@ -122,9 +227,9 @@ export default class extends Controller {
|
|
|
122
227
|
const range = selection.getRangeAt(0)
|
|
123
228
|
if (this.rangeInsideEditor(range)) {
|
|
124
229
|
this.savedRange = range.cloneRange()
|
|
125
|
-
this.debug("rememberSelection:saved", this.describeRange(this.savedRange))
|
|
230
|
+
this.debug("rememberSelection:saved", () => (this.describeRange(this.savedRange)))
|
|
126
231
|
} else {
|
|
127
|
-
this.debug("rememberSelection:outside", this.describeRange(range))
|
|
232
|
+
this.debug("rememberSelection:outside", () => (this.describeRange(range)))
|
|
128
233
|
}
|
|
129
234
|
}
|
|
130
235
|
|
|
@@ -134,17 +239,17 @@ export default class extends Controller {
|
|
|
134
239
|
const selection = window.getSelection()
|
|
135
240
|
selection.removeAllRanges()
|
|
136
241
|
selection.addRange(this.savedRange)
|
|
137
|
-
this.debug("restoreSelection:saved", this.describeRange(this.savedRange))
|
|
242
|
+
this.debug("restoreSelection:saved", () => (this.describeRange(this.savedRange)))
|
|
138
243
|
return
|
|
139
244
|
}
|
|
140
245
|
|
|
141
|
-
this.debug("restoreSelection:endFallback", this.snapshot())
|
|
246
|
+
this.debug("restoreSelection:endFallback", () => (this.snapshot()))
|
|
142
247
|
this.placeCaretAtEnd()
|
|
143
248
|
}
|
|
144
249
|
|
|
145
250
|
insertLink(rawUrl) {
|
|
146
251
|
const url = this.normalizeUrl(rawUrl)
|
|
147
|
-
this.debug("insertLink:start", { rawUrl, url, before: this.snapshot() })
|
|
252
|
+
this.debug("insertLink:start", () => ({ rawUrl, url, before: this.snapshot() }))
|
|
148
253
|
if (!url) return
|
|
149
254
|
|
|
150
255
|
const range = this.activeRange()
|
|
@@ -157,7 +262,7 @@ export default class extends Controller {
|
|
|
157
262
|
anchor.href = url
|
|
158
263
|
anchor.rel = "noopener noreferrer"
|
|
159
264
|
anchor.target = "_blank"
|
|
160
|
-
this.debug("insertLink:range", this.describeRange(range))
|
|
265
|
+
this.debug("insertLink:range", () => (this.describeRange(range)))
|
|
161
266
|
|
|
162
267
|
if (range.collapsed) {
|
|
163
268
|
anchor.textContent = url
|
|
@@ -172,12 +277,12 @@ export default class extends Controller {
|
|
|
172
277
|
const selection = window.getSelection()
|
|
173
278
|
selection.removeAllRanges()
|
|
174
279
|
selection.addRange(range)
|
|
175
|
-
this.debug("insertLink:done", { anchor: anchor.outerHTML, after: this.snapshot() })
|
|
280
|
+
this.debug("insertLink:done", () => ({ anchor: anchor.outerHTML, after: this.snapshot() }))
|
|
176
281
|
}
|
|
177
282
|
|
|
178
283
|
toggleList(tagName) {
|
|
179
284
|
const range = this.activeRange()
|
|
180
|
-
this.debug("toggleList:start", { tagName, range: this.describeRange(range), before: this.snapshot() })
|
|
285
|
+
this.debug("toggleList:start", () => ({ tagName, range: this.describeRange(range), before: this.snapshot() }))
|
|
181
286
|
if (!range) return
|
|
182
287
|
|
|
183
288
|
const activeList = this.closestElement(range.startContainer, "ul, ol")
|
|
@@ -187,12 +292,12 @@ export default class extends Controller {
|
|
|
187
292
|
} else {
|
|
188
293
|
this.convertList(activeList, tagName)
|
|
189
294
|
}
|
|
190
|
-
this.debug("toggleList:existingList", { tagName, after: this.snapshot() })
|
|
295
|
+
this.debug("toggleList:existingList", () => ({ tagName, after: this.snapshot() }))
|
|
191
296
|
return
|
|
192
297
|
}
|
|
193
298
|
|
|
194
299
|
const blocks = this.selectedBlocks(range)
|
|
195
|
-
this.debug("toggleList:blocks", { tagName, blocks: blocks.map((block) => block.outerHTML) })
|
|
300
|
+
this.debug("toggleList:blocks", () => ({ tagName, blocks: blocks.map((block) => block.outerHTML) }))
|
|
196
301
|
if (blocks.length === 0) return
|
|
197
302
|
|
|
198
303
|
const list = document.createElement(tagName)
|
|
@@ -204,15 +309,15 @@ export default class extends Controller {
|
|
|
204
309
|
})
|
|
205
310
|
|
|
206
311
|
blocks[0].before(list)
|
|
207
|
-
blocks.forEach((block) => block.remove())
|
|
312
|
+
blocks.forEach((block) => { block.remove() })
|
|
208
313
|
this.selectNodeContents(list)
|
|
209
|
-
this.debug("toggleList:done", { list: list.outerHTML, after: this.snapshot() })
|
|
314
|
+
this.debug("toggleList:done", () => ({ list: list.outerHTML, after: this.snapshot() }))
|
|
210
315
|
}
|
|
211
316
|
|
|
212
317
|
formatBlocks(tagName) {
|
|
213
318
|
const normalizedTagName = this.normalizedBlockTag(tagName)
|
|
214
319
|
const range = this.activeRange()
|
|
215
|
-
this.debug("formatBlocks:start", { tagName: normalizedTagName, range: this.describeRange(range) })
|
|
320
|
+
this.debug("formatBlocks:start", () => ({ tagName: normalizedTagName, range: this.describeRange(range) }))
|
|
216
321
|
if (!range) return
|
|
217
322
|
|
|
218
323
|
const blocks = this.selectedBlocks(range)
|
|
@@ -220,13 +325,13 @@ export default class extends Controller {
|
|
|
220
325
|
|
|
221
326
|
const replacements = blocks.map((block) => this.replaceBlock(block, normalizedTagName))
|
|
222
327
|
this.selectNodeContents(replacements[replacements.length - 1])
|
|
223
|
-
this.debug("formatBlocks:done", { tagName: normalizedTagName, blocks: replacements.map((block) => block.outerHTML) })
|
|
328
|
+
this.debug("formatBlocks:done", () => ({ tagName: normalizedTagName, blocks: replacements.map((block) => block.outerHTML) }))
|
|
224
329
|
}
|
|
225
330
|
|
|
226
331
|
alignBlocks(alignment) {
|
|
227
332
|
const normalizedAlignment = this.normalizedAlignment(alignment)
|
|
228
333
|
const range = this.activeRange()
|
|
229
|
-
this.debug("alignBlocks:start", { alignment: normalizedAlignment, range: this.describeRange(range) })
|
|
334
|
+
this.debug("alignBlocks:start", () => ({ alignment: normalizedAlignment, range: this.describeRange(range) }))
|
|
230
335
|
if (!range) return
|
|
231
336
|
|
|
232
337
|
const blocks = this.selectedBlocks(range)
|
|
@@ -239,7 +344,7 @@ export default class extends Controller {
|
|
|
239
344
|
})
|
|
240
345
|
|
|
241
346
|
if (blocks.length > 0) this.selectNodeContents(blocks[blocks.length - 1])
|
|
242
|
-
this.debug("alignBlocks:done", { alignment: normalizedAlignment, blocks: blocks.map((block) => block.outerHTML) })
|
|
347
|
+
this.debug("alignBlocks:done", () => ({ alignment: normalizedAlignment, blocks: blocks.map((block) => block.outerHTML) }))
|
|
243
348
|
}
|
|
244
349
|
|
|
245
350
|
unwrapList(list) {
|
|
@@ -279,11 +384,41 @@ export default class extends Controller {
|
|
|
279
384
|
}
|
|
280
385
|
}
|
|
281
386
|
|
|
387
|
+
// Shapes that are unsafe in every context. Browsers treat "\" as "/" and
|
|
388
|
+
// strip TAB/CR/LF before parsing, and any leading "//" is protocol-relative
|
|
389
|
+
// however many slashes follow — "///evil.example" resolves to
|
|
390
|
+
// https://evil.example/.
|
|
391
|
+
rejectedUrlShape(url) {
|
|
392
|
+
if (url.length === 0) return true
|
|
393
|
+
if (url.includes("\\")) return true
|
|
394
|
+
if ([...url].some((ch) => ch.charCodeAt(0) <= 0x1f || ch.charCodeAt(0) === 0x7f)) return true
|
|
395
|
+
|
|
396
|
+
return url.startsWith("//")
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Policy for an href that already exists in markup — a pasted anchor, or one
|
|
400
|
+
// being opened. It mirrors safe_url on the server: a relative reference stays
|
|
401
|
+
// relative, because in HTML `href="settings"` means "relative to this page",
|
|
402
|
+
// not "the host settings". Promoting it to https://settings/ would turn a
|
|
403
|
+
// same-origin link into an off-origin one.
|
|
404
|
+
normalizeHref(rawUrl) {
|
|
405
|
+
const url = String(rawUrl || "").trim()
|
|
406
|
+
if (this.rejectedUrlShape(url)) return null
|
|
407
|
+
if (url.startsWith("#") || url.startsWith("/")) return url
|
|
408
|
+
|
|
409
|
+
const scheme = url.match(/^[a-z][a-z0-9+.-]*:/i)
|
|
410
|
+
if (!scheme) return url
|
|
411
|
+
|
|
412
|
+
return ALLOWED_LINK_PROTOCOLS.has(scheme[0].toLowerCase()) ? url : null
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Policy for a URL a person typed into the link prompt, where a bare host is
|
|
416
|
+
// the common case and should become https://host/. Only ever applied to input
|
|
417
|
+
// the user just entered, never to markup.
|
|
282
418
|
normalizeUrl(rawUrl) {
|
|
283
419
|
const url = String(rawUrl || "").trim()
|
|
284
|
-
if (url
|
|
285
|
-
if (url.startsWith("#")) return url
|
|
286
|
-
if (url.startsWith("/") && !url.startsWith("//")) return url
|
|
420
|
+
if (this.rejectedUrlShape(url)) return null
|
|
421
|
+
if (url.startsWith("#") || url.startsWith("/")) return url
|
|
287
422
|
|
|
288
423
|
const candidate = /^[a-z][a-z0-9+.-]*:/i.test(url) ? url : `https://${url}`
|
|
289
424
|
try {
|
|
@@ -299,19 +434,19 @@ export default class extends Controller {
|
|
|
299
434
|
if (selection && selection.rangeCount > 0) {
|
|
300
435
|
const range = selection.getRangeAt(0)
|
|
301
436
|
if (this.rangeInsideEditor(range)) {
|
|
302
|
-
this.debug("activeRange:selection", this.describeRange(range))
|
|
437
|
+
this.debug("activeRange:selection", () => (this.describeRange(range)))
|
|
303
438
|
return range
|
|
304
439
|
}
|
|
305
440
|
}
|
|
306
441
|
|
|
307
442
|
if (this.savedRange && this.rangeInsideEditor(this.savedRange)) {
|
|
308
|
-
this.debug("activeRange:saved", this.describeRange(this.savedRange))
|
|
443
|
+
this.debug("activeRange:saved", () => (this.describeRange(this.savedRange)))
|
|
309
444
|
return this.savedRange
|
|
310
445
|
}
|
|
311
446
|
|
|
312
447
|
this.placeCaretAtEnd()
|
|
313
448
|
const range = window.getSelection().getRangeAt(0)
|
|
314
|
-
this.debug("activeRange:endFallback", this.describeRange(range))
|
|
449
|
+
this.debug("activeRange:endFallback", () => (this.describeRange(range)))
|
|
315
450
|
return range
|
|
316
451
|
}
|
|
317
452
|
|
|
@@ -445,9 +580,11 @@ export default class extends Controller {
|
|
|
445
580
|
return `${node.tagName.toLowerCase()}${id}${classes}`
|
|
446
581
|
}
|
|
447
582
|
|
|
583
|
+
// `detail` may be a thunk so callers can defer building expensive payloads
|
|
584
|
+
// (full innerHTML/textContent snapshots) until debugging is actually on.
|
|
448
585
|
debug(message, detail = {}) {
|
|
449
586
|
if (!this.debugValue) return
|
|
450
587
|
|
|
451
|
-
console.debug("[senren rich text]", message, detail)
|
|
588
|
+
console.debug("[senren rich text]", message, typeof detail === "function" ? detail() : detail)
|
|
452
589
|
}
|
|
453
590
|
}
|
|
@@ -2,33 +2,63 @@ import { Controller } from "@hotwired/stimulus"
|
|
|
2
2
|
|
|
3
3
|
// senren--sheet
|
|
4
4
|
// Side panel; behaves like a dialog.
|
|
5
|
+
//
|
|
6
|
+
// State lives in `openValue`. Actions assign it; openValueChanged performs every
|
|
7
|
+
// DOM change, so the panel can be opened from the server and survives a morph.
|
|
5
8
|
export default class extends Controller {
|
|
6
9
|
static targets = ["overlay", "panel", "trigger"]
|
|
10
|
+
static values = { open: Boolean }
|
|
11
|
+
|
|
12
|
+
connect() {
|
|
13
|
+
this._onKey = this._onKey.bind(this)
|
|
14
|
+
this._onBeforeCache = this._onBeforeCache.bind(this)
|
|
15
|
+
document.addEventListener("turbo:before-cache", this._onBeforeCache)
|
|
16
|
+
}
|
|
7
17
|
|
|
8
|
-
connect() { this._onKey = this._onKey.bind(this) }
|
|
9
18
|
disconnect() {
|
|
10
19
|
document.removeEventListener("keydown", this._onKey)
|
|
20
|
+
document.removeEventListener("turbo:before-cache", this._onBeforeCache)
|
|
11
21
|
document.body.style.overflow = ""
|
|
12
22
|
}
|
|
13
23
|
|
|
14
24
|
open(event) {
|
|
15
25
|
event?.preventDefault()
|
|
16
|
-
this.
|
|
17
|
-
this.panelTarget.hidden = false
|
|
18
|
-
this.panelTarget.dataset.open = "true"
|
|
19
|
-
document.addEventListener("keydown", this._onKey)
|
|
20
|
-
document.body.style.overflow = "hidden"
|
|
21
|
-
queueMicrotask(() => this.panelTarget?.focus())
|
|
26
|
+
this.openValue = true
|
|
22
27
|
}
|
|
23
28
|
|
|
24
29
|
close(event) {
|
|
25
30
|
event?.preventDefault()
|
|
26
|
-
this.
|
|
27
|
-
|
|
28
|
-
|
|
31
|
+
this.openValue = false
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
openValueChanged(isOpen, wasOpen) {
|
|
35
|
+
if (this.hasOverlayTarget) this.overlayTarget.hidden = !isOpen
|
|
36
|
+
if (this.hasPanelTarget) {
|
|
37
|
+
this.panelTarget.hidden = !isOpen
|
|
38
|
+
this.panelTarget.dataset.open = isOpen ? "true" : "false"
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (isOpen) {
|
|
42
|
+
document.addEventListener("keydown", this._onKey)
|
|
43
|
+
document.body.style.overflow = "hidden"
|
|
44
|
+
queueMicrotask(() => this.panelTarget?.focus())
|
|
45
|
+
this.dispatch("opened")
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
|
|
29
49
|
document.removeEventListener("keydown", this._onKey)
|
|
30
50
|
document.body.style.overflow = ""
|
|
51
|
+
if (wasOpen !== undefined) this.dispatch("closed")
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
_onKey(event) {
|
|
55
|
+
if (event.key === "Escape") this.close()
|
|
31
56
|
}
|
|
32
57
|
|
|
33
|
-
|
|
58
|
+
// Turbo snapshots the page before navigating away; without this the scroll
|
|
59
|
+
// lock is captured while applied and a back navigation restores a page that
|
|
60
|
+
// cannot be scrolled.
|
|
61
|
+
_onBeforeCache() {
|
|
62
|
+
this.openValue = false
|
|
63
|
+
}
|
|
34
64
|
}
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: senren-ui
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- vutt
|
|
@@ -9,20 +9,6 @@ bindir: bin
|
|
|
9
9
|
cert_chain: []
|
|
10
10
|
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
|
-
- !ruby/object:Gem::Dependency
|
|
13
|
-
name: nokogiri
|
|
14
|
-
requirement: !ruby/object:Gem::Requirement
|
|
15
|
-
requirements:
|
|
16
|
-
- - ">="
|
|
17
|
-
- !ruby/object:Gem::Version
|
|
18
|
-
version: 1.19.3
|
|
19
|
-
type: :runtime
|
|
20
|
-
prerelease: false
|
|
21
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
-
requirements:
|
|
23
|
-
- - ">="
|
|
24
|
-
- !ruby/object:Gem::Version
|
|
25
|
-
version: 1.19.3
|
|
26
12
|
- !ruby/object:Gem::Dependency
|
|
27
13
|
name: rails
|
|
28
14
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -68,6 +54,7 @@ files:
|
|
|
68
54
|
- README.md
|
|
69
55
|
- Rakefile
|
|
70
56
|
- docs/components.md
|
|
57
|
+
- docs/hot_reload.md
|
|
71
58
|
- docs/performance_testing.md
|
|
72
59
|
- docs/visual_style.md
|
|
73
60
|
- lib/commands/senren/add/add_command.rb
|
|
@@ -82,16 +69,20 @@ files:
|
|
|
82
69
|
- lib/generators/senren/install/templates/conventions.md.tt
|
|
83
70
|
- lib/generators/senren/install/templates/installed_components.yml.tt
|
|
84
71
|
- lib/generators/senren/install/templates/senren.css.tt
|
|
72
|
+
- lib/senren-ui.rb
|
|
85
73
|
- lib/senren/rails.rb
|
|
86
74
|
- lib/senren/rails/agent_rules_writer.rb
|
|
75
|
+
- lib/senren/rails/asset_path_guard.rb
|
|
76
|
+
- lib/senren/rails/base_component_patch.rb
|
|
87
77
|
- lib/senren/rails/component_copier.rb
|
|
88
78
|
- lib/senren/rails/component_installer.rb
|
|
89
79
|
- lib/senren/rails/doctor.rb
|
|
90
80
|
- lib/senren/rails/engine.rb
|
|
91
81
|
- lib/senren/rails/host_paths.rb
|
|
92
|
-
- lib/senren/rails/installer.rb
|
|
93
82
|
- lib/senren/rails/llms_writer.rb
|
|
83
|
+
- lib/senren/rails/marker_block.rb
|
|
94
84
|
- lib/senren/rails/registry.rb
|
|
85
|
+
- lib/senren/rails/safe_write.rb
|
|
95
86
|
- lib/senren/rails/skill_writer.rb
|
|
96
87
|
- lib/senren/rails/version.rb
|
|
97
88
|
- lib/tasks/senren.rake
|
|
@@ -130,6 +121,8 @@ files:
|
|
|
130
121
|
- templates/components/card/card_component.rb
|
|
131
122
|
- templates/components/carousel/carousel_component.html.erb
|
|
132
123
|
- templates/components/carousel/carousel_component.rb
|
|
124
|
+
- templates/components/cart/cart_component.html.erb
|
|
125
|
+
- templates/components/cart/cart_component.rb
|
|
133
126
|
- templates/components/checkbox/checkbox_component.html.erb
|
|
134
127
|
- templates/components/checkbox/checkbox_component.rb
|
|
135
128
|
- templates/components/checkbox_group/checkbox_group_component.html.erb
|
|
@@ -180,6 +173,8 @@ files:
|
|
|
180
173
|
- templates/components/pagination/pagination_component.rb
|
|
181
174
|
- templates/components/popover/popover_component.html.erb
|
|
182
175
|
- templates/components/popover/popover_component.rb
|
|
176
|
+
- templates/components/product_card/product_card_component.html.erb
|
|
177
|
+
- templates/components/product_card/product_card_component.rb
|
|
183
178
|
- templates/components/progress/progress_component.html.erb
|
|
184
179
|
- templates/components/progress/progress_component.rb
|
|
185
180
|
- templates/components/radio_button/radio_button_component.html.erb
|
|
@@ -227,6 +222,7 @@ files:
|
|
|
227
222
|
- templates/controllers/api_key_field_controller.js
|
|
228
223
|
- templates/controllers/calendar_controller.js
|
|
229
224
|
- templates/controllers/carousel_controller.js
|
|
225
|
+
- templates/controllers/cart_controller.js
|
|
230
226
|
- templates/controllers/clipboard_controller.js
|
|
231
227
|
- templates/controllers/collapsible_controller.js
|
|
232
228
|
- templates/controllers/combobox_controller.js
|
|
@@ -270,7 +266,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
270
266
|
- !ruby/object:Gem::Version
|
|
271
267
|
version: '0'
|
|
272
268
|
requirements: []
|
|
273
|
-
rubygems_version: 4.0.
|
|
269
|
+
rubygems_version: 4.0.6
|
|
274
270
|
specification_version: 4
|
|
275
271
|
summary: Rails-native UI component library with ViewComponent, Hotwire, Tailwind,
|
|
276
272
|
and AI-agent skill files.
|
|
@@ -1,85 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require 'fileutils'
|
|
4
|
-
|
|
5
|
-
module Senren
|
|
6
|
-
module Rails
|
|
7
|
-
# Idempotent installer that lays down the .senren directory, base
|
|
8
|
-
# component, stylesheet, and agent instruction files. Reused by the install
|
|
9
|
-
# generator and by ad-hoc rake task entry points.
|
|
10
|
-
class Installer
|
|
11
|
-
attr_reader :paths, :stdout
|
|
12
|
-
|
|
13
|
-
def initialize(paths: HostPaths.new, stdout: $stdout)
|
|
14
|
-
@paths = paths
|
|
15
|
-
@stdout = stdout
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
def run!(force: false)
|
|
19
|
-
paths.ensure_dirs!
|
|
20
|
-
install_static_files(force: force)
|
|
21
|
-
mirror_registry
|
|
22
|
-
SkillWriter.new(paths: paths).sync!
|
|
23
|
-
AgentRulesWriter.new(paths: paths).sync!
|
|
24
|
-
print_next_steps
|
|
25
|
-
true
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
INSTALL_GENERATOR_TEMPLATES = File.expand_path(
|
|
29
|
-
'../../generators/senren/install/templates', __dir__
|
|
30
|
-
).freeze
|
|
31
|
-
|
|
32
|
-
STATIC_FILES = {
|
|
33
|
-
'conventions.md.tt' => :conventions_file,
|
|
34
|
-
'installed_components.yml.tt' => :installed_components,
|
|
35
|
-
'base_component.rb.tt' => :base_component_path,
|
|
36
|
-
'senren.css.tt' => :stylesheet_path
|
|
37
|
-
}.freeze
|
|
38
|
-
|
|
39
|
-
private
|
|
40
|
-
|
|
41
|
-
def install_static_files(force:)
|
|
42
|
-
STATIC_FILES.each do |template_basename, paths_method|
|
|
43
|
-
src = File.join(INSTALL_GENERATOR_TEMPLATES, template_basename)
|
|
44
|
-
dest = paths.public_send(paths_method)
|
|
45
|
-
copy_template(src, dest, force: force)
|
|
46
|
-
end
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
def copy_template(src, dest, force:)
|
|
50
|
-
unless File.exist?(src)
|
|
51
|
-
stdout.puts " warn template missing: #{src}"
|
|
52
|
-
return
|
|
53
|
-
end
|
|
54
|
-
if File.exist?(dest) && !force
|
|
55
|
-
stdout.puts " skip #{dest}"
|
|
56
|
-
return
|
|
57
|
-
end
|
|
58
|
-
FileUtils.mkdir_p(File.dirname(dest))
|
|
59
|
-
FileUtils.cp(src, dest)
|
|
60
|
-
stdout.puts " copy #{dest}"
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
def mirror_registry
|
|
64
|
-
FileUtils.mkdir_p(paths.registry_mirror.dirname)
|
|
65
|
-
FileUtils.cp(Senren::Rails.registry_path, paths.registry_mirror)
|
|
66
|
-
stdout.puts " mirror #{paths.registry_mirror}"
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
def print_next_steps
|
|
70
|
-
stdout.puts <<~MSG
|
|
71
|
-
|
|
72
|
-
Senren installed. Next steps:
|
|
73
|
-
|
|
74
|
-
bin/rails senren:add button card badge alert
|
|
75
|
-
bin/rails senren:add dialog dropdown_menu
|
|
76
|
-
bundle exec rails senren:add form input textarea
|
|
77
|
-
bin/rails senren:agents:sync
|
|
78
|
-
bin/rails senren:doctor
|
|
79
|
-
|
|
80
|
-
Read .senren/skill.md and .senren/conventions.md to get oriented.
|
|
81
|
-
MSG
|
|
82
|
-
end
|
|
83
|
-
end
|
|
84
|
-
end
|
|
85
|
-
end
|