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.
Files changed (68) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +104 -0
  3. data/CONTRIBUTING.md +40 -3
  4. data/README.md +110 -33
  5. data/Rakefile +14 -1
  6. data/docs/components.md +10 -10
  7. data/docs/hot_reload.md +103 -0
  8. data/docs/performance_testing.md +7 -3
  9. data/lib/generators/senren/component/templates/controller.js.tt +7 -4
  10. data/lib/generators/senren/install/install_generator.rb +74 -0
  11. data/lib/generators/senren/install/templates/base_component.rb.tt +52 -4
  12. data/lib/generators/senren/install/templates/conventions.md.tt +16 -1
  13. data/lib/senren/rails/agent_rules_writer.rb +62 -19
  14. data/lib/senren/rails/asset_path_guard.rb +128 -0
  15. data/lib/senren/rails/base_component_patch.rb +64 -0
  16. data/lib/senren/rails/component_copier.rb +101 -50
  17. data/lib/senren/rails/component_installer.rb +26 -0
  18. data/lib/senren/rails/doctor.rb +7 -4
  19. data/lib/senren/rails/engine.rb +23 -0
  20. data/lib/senren/rails/host_paths.rb +11 -2
  21. data/lib/senren/rails/marker_block.rb +81 -0
  22. data/lib/senren/rails/registry.rb +10 -4
  23. data/lib/senren/rails/safe_write.rb +169 -0
  24. data/lib/senren/rails/skill_writer.rb +47 -11
  25. data/lib/senren/rails/version.rb +1 -1
  26. data/lib/senren/rails.rb +1 -1
  27. data/lib/senren-ui.rb +15 -0
  28. data/lib/tasks/senren.rake +43 -17
  29. data/registry/components.yml +45 -0
  30. data/registry/recipes.yml +12 -0
  31. data/templates/components/alert_dialog/alert_dialog_component.rb +1 -1
  32. data/templates/components/api_key_field/api_key_field_component.html.erb +1 -1
  33. data/templates/components/aspect_ratio/aspect_ratio_component.rb +7 -0
  34. data/templates/components/avatar/avatar_component.rb +8 -1
  35. data/templates/components/cart/cart_component.html.erb +61 -0
  36. data/templates/components/cart/cart_component.rb +71 -0
  37. data/templates/components/checkbox/checkbox_component.rb +1 -1
  38. data/templates/components/clipboard/clipboard_component.html.erb +1 -1
  39. data/templates/components/command/command_component.rb +1 -1
  40. data/templates/components/date_picker/date_picker_component.html.erb +1 -1
  41. data/templates/components/dialog/dialog_component.rb +1 -1
  42. data/templates/components/form/form_component.rb +9 -1
  43. data/templates/components/invite_member_dialog/invite_member_dialog_component.rb +1 -1
  44. data/templates/components/product_card/product_card_component.html.erb +38 -0
  45. data/templates/components/product_card/product_card_component.rb +49 -0
  46. data/templates/components/rich_text_editor_lite/rich_text_editor_lite_component.html.erb +1 -1
  47. data/templates/components/rich_text_editor_lite/rich_text_editor_lite_component.rb +1 -1
  48. data/templates/components/separator/separator_component.rb +7 -0
  49. data/templates/components/sheet/sheet_component.rb +1 -1
  50. data/templates/components/tooltip/tooltip_component.rb +2 -2
  51. data/templates/components/typography/typography_component.rb +7 -0
  52. data/templates/controllers/accordion_controller.js +1 -1
  53. data/templates/controllers/alert_dialog_controller.js +31 -7
  54. data/templates/controllers/cart_controller.js +83 -0
  55. data/templates/controllers/clipboard_controller.js +12 -1
  56. data/templates/controllers/command_controller.js +3 -4
  57. data/templates/controllers/context_menu_controller.js +38 -11
  58. data/templates/controllers/data_table_controller.js +8 -3
  59. data/templates/controllers/dialog_controller.js +47 -21
  60. data/templates/controllers/dropdown_menu_controller.js +40 -27
  61. data/templates/controllers/hover_card_controller.js +8 -0
  62. data/templates/controllers/invite_member_dialog_controller.js +6 -0
  63. data/templates/controllers/masked_input_controller.js +8 -1
  64. data/templates/controllers/popover_controller.js +25 -10
  65. data/templates/controllers/rich_text_editor_lite_controller.js +165 -28
  66. data/templates/controllers/sheet_controller.js +41 -11
  67. metadata +13 -17
  68. 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
- document.removeEventListener("click", this._onDocClick)
15
- document.removeEventListener("keydown", this._onKey)
19
+ this._stopListening()
16
20
  }
17
21
 
18
22
  toggle(event) {
19
23
  event?.preventDefault()
20
- this.panelTarget.hidden ? this._show() : this.close()
24
+ this.openValue = !this.openValue
21
25
  }
22
26
 
23
27
  close() {
24
- this.panelTarget.hidden = true
25
- document.removeEventListener("click", this._onDocClick)
26
- document.removeEventListener("keydown", this._onKey)
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
- _show() {
30
- this.panelTarget.hidden = false
31
- document.addEventListener("click", this._onDocClick)
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
- window.open(link.href, "_blank", "noopener,noreferrer")
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.length === 0) return null
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.overlayTarget.hidden = false
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.overlayTarget.hidden = true
27
- this.panelTarget.hidden = true
28
- this.panelTarget.dataset.open = "false"
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
- _onKey(event) { if (event.key === "Escape") this.close() }
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.1.6
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.3
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