rip-lang 3.15.4 → 3.16.1

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 (112) hide show
  1. package/README.md +6 -4
  2. package/bin/rip +167 -12
  3. package/docs/AGENTS.md +1 -1
  4. package/docs/RIP-APP.md +808 -0
  5. package/docs/RIP-DUCKDB.md +477 -0
  6. package/docs/RIP-INTRO.md +396 -0
  7. package/docs/RIP-LANG.md +59 -5
  8. package/docs/RIP-SCHEMA.md +191 -8
  9. package/docs/RIP-TYPES.md +74 -103
  10. package/docs/demo/README.md +4 -3
  11. package/docs/dist/rip.js +3627 -1470
  12. package/docs/dist/rip.min.js +671 -244
  13. package/docs/dist/rip.min.js.br +0 -0
  14. package/docs/example/index.json +7 -7
  15. package/docs/example/index.json.br +0 -0
  16. package/docs/extensions/duckdb/manifest.json +1 -1
  17. package/docs/extensions/duckdb/v1.5.2/linux_amd64/ripdb.duckdb_extension.gz +0 -0
  18. package/docs/extensions/duckdb/v1.5.2/osx_arm64/ripdb.duckdb_extension.gz +0 -0
  19. package/docs/extensions/vscode/print/index.html +2 -1
  20. package/docs/extensions/vscode/print/print-1.0.13.vsix +0 -0
  21. package/docs/extensions/vscode/print/print-1.0.14.vsix +0 -0
  22. package/docs/extensions/vscode/print/print-latest.vsix +0 -0
  23. package/docs/extensions/vscode/rip/rip-0.5.15.vsix +0 -0
  24. package/docs/extensions/vscode/rip/rip-latest.vsix +0 -0
  25. package/docs/ui/bundle.json +61 -0
  26. package/docs/ui/bundle.json.br +0 -0
  27. package/docs/ui/hljs-rip.js +0 -7
  28. package/docs/ui/index.css +66 -23
  29. package/docs/ui/index.html +6 -6
  30. package/package.json +9 -3
  31. package/rip-loader.js +64 -2
  32. package/src/AGENTS.md +63 -36
  33. package/src/browser.js +96 -14
  34. package/src/compiler.js +960 -143
  35. package/src/components.js +794 -88
  36. package/src/{types-emit.js → dts.js} +181 -71
  37. package/src/grammar/README.md +1 -1
  38. package/src/grammar/grammar.rip +111 -97
  39. package/src/lexer.js +132 -18
  40. package/src/parser.js +203 -205
  41. package/src/repl.js +74 -6
  42. package/src/schema/runtime-orm.js +168 -4
  43. package/src/schema/runtime-validate.js +146 -2
  44. package/src/schema/runtime.generated.js +314 -6
  45. package/src/schema/schema.js +5 -5
  46. package/src/sourcemaps.js +277 -1
  47. package/src/stdlib.js +253 -0
  48. package/src/typecheck.js +2023 -106
  49. package/src/types.js +127 -7
  50. package/docs/ui/accordion.rip +0 -103
  51. package/docs/ui/alert-dialog.rip +0 -53
  52. package/docs/ui/autocomplete.rip +0 -115
  53. package/docs/ui/avatar.rip +0 -37
  54. package/docs/ui/badge.rip +0 -15
  55. package/docs/ui/breadcrumb.rip +0 -47
  56. package/docs/ui/button-group.rip +0 -26
  57. package/docs/ui/button.rip +0 -23
  58. package/docs/ui/card.rip +0 -25
  59. package/docs/ui/carousel.rip +0 -110
  60. package/docs/ui/checkbox-group.rip +0 -61
  61. package/docs/ui/checkbox.rip +0 -33
  62. package/docs/ui/collapsible.rip +0 -50
  63. package/docs/ui/combobox.rip +0 -130
  64. package/docs/ui/context-menu.rip +0 -88
  65. package/docs/ui/date-picker.rip +0 -206
  66. package/docs/ui/dialog.rip +0 -60
  67. package/docs/ui/drawer.rip +0 -58
  68. package/docs/ui/editable-value.rip +0 -82
  69. package/docs/ui/field.rip +0 -53
  70. package/docs/ui/fieldset.rip +0 -22
  71. package/docs/ui/form.rip +0 -39
  72. package/docs/ui/grid.rip +0 -901
  73. package/docs/ui/input-group.rip +0 -28
  74. package/docs/ui/input.rip +0 -36
  75. package/docs/ui/label.rip +0 -16
  76. package/docs/ui/menu.rip +0 -134
  77. package/docs/ui/menubar.rip +0 -151
  78. package/docs/ui/meter.rip +0 -36
  79. package/docs/ui/multi-select.rip +0 -203
  80. package/docs/ui/native-select.rip +0 -33
  81. package/docs/ui/nav-menu.rip +0 -126
  82. package/docs/ui/number-field.rip +0 -162
  83. package/docs/ui/otp-field.rip +0 -89
  84. package/docs/ui/pagination.rip +0 -123
  85. package/docs/ui/popover.rip +0 -93
  86. package/docs/ui/preview-card.rip +0 -75
  87. package/docs/ui/progress.rip +0 -25
  88. package/docs/ui/radio-group.rip +0 -57
  89. package/docs/ui/resizable.rip +0 -123
  90. package/docs/ui/scroll-area.rip +0 -145
  91. package/docs/ui/select.rip +0 -151
  92. package/docs/ui/separator.rip +0 -17
  93. package/docs/ui/skeleton.rip +0 -22
  94. package/docs/ui/slider.rip +0 -165
  95. package/docs/ui/spinner.rip +0 -17
  96. package/docs/ui/table.rip +0 -27
  97. package/docs/ui/tabs.rip +0 -113
  98. package/docs/ui/textarea.rip +0 -48
  99. package/docs/ui/toast.rip +0 -87
  100. package/docs/ui/toggle-group.rip +0 -71
  101. package/docs/ui/toggle.rip +0 -24
  102. package/docs/ui/toolbar.rip +0 -38
  103. package/docs/ui/tooltip.rip +0 -85
  104. package/src/app.rip +0 -1571
  105. package/src/sourcemap-merge.js +0 -287
  106. /package/docs/demo/{components → routes}/_layout.rip +0 -0
  107. /package/docs/demo/{components → routes}/about.rip +0 -0
  108. /package/docs/demo/{components → routes}/card.rip +0 -0
  109. /package/docs/demo/{components → routes}/counter.rip +0 -0
  110. /package/docs/demo/{components → routes}/index.rip +0 -0
  111. /package/docs/demo/{components → routes}/todos.rip +0 -0
  112. /package/src/schema/{dts-emit.js → dts.js} +0 -0
@@ -0,0 +1,61 @@
1
+ {
2
+ "modules": {
3
+ "_pkg/ui/meter.rip": "# Meter — accessible headless meter (gauge)\n#\n# For known-range measurements (disk usage, password strength, etc.).\n# Exposes value and thresholds as CSS custom properties.\n# Ships zero CSS.\n#\n# Usage:\n# Meter value: 0.7, low: 0.3, high: 0.8, optimum: 0.5\n# Meter value: 75, min: 0, max: 100\n\nexport Meter = component\n @value := 0\n @min := 0\n @max := 1\n @low := null\n @high := null\n @optimum := null\n @label := null\n\n percent ~= Math.min(100, Math.max(0, ((@value - @min) / (@max - @min)) * 100))\n\n level ~=\n return 'optimum' unless @low? and @high?\n if @value <= @low then 'low'\n else if @value >= @high then 'high'\n else 'optimum'\n\n render\n div role: \"meter\"\n aria-valuenow: @value\n aria-valuemin: @min\n aria-valuemax: @max\n aria-label: @label?!\n style: \"--meter-value: #{@value}; --meter-percent: #{percent}%\"\n $level: level\n slot\n",
4
+ "_pkg/ui/grid.rip": "# Grid — high-performance headless data grid with virtual scrolling\n#\n# Renders 100K+ rows at 60fps. Semantic <table> with sticky header.\n# Selection, keyboard navigation, inline editing, sorting, column resize.\n# Exposes data-* attributes for all interactive states. Themeable via\n# CSS custom properties (--grid-*). Ships zero layout CSS — bring your own.\n#\n# Usage:\n# Grid\n# data: employees\n# columns: [\n# { key: 'name', title: 'Name', width: 200 }\n# { key: 'age', title: 'Age', width: 80, align: 'right' }\n# { key: 'role', title: 'Role', width: 150, type: 'select', source: roles }\n# { key: 'active', title: 'Active', width: 60, type: 'checkbox' }\n# ]\n# rowHeight: 32\n# overscan: 5\n# @beforeEdit: (row, col, oldVal, newVal) -> newVal\n# @afterEdit: (row, col, oldVal, newVal) -> null\n\nexport Grid = component\n\n @data := []\n @columns := []\n @rowHeight := 32\n @headerHeight := 36\n @overscan := 5\n @striped := false\n @beforeEdit := null\n @afterEdit := null\n\n formatRegistry = []\n\n registerFormat = (code, formatFn, parseFn) ->\n id = formatRegistry.length\n formatRegistry.push({ code, format: formatFn, parse: parseFn })\n id\n\n formatMap := new Map()\n\n _resolveCol = (ci) ->\n fid = formatMap.get(\"0,#{ci + 1}\")\n if fid isnt undefined then return formatRegistry[fid]\n gid = formatMap.get(\"0,0\")\n if gid isnt undefined then formatRegistry[gid] else null\n\n colFormatCache ~=\n cache = []\n ci = 0\n while ci < @columns.length\n cache.push(_resolveCol(ci))\n ci++\n cache\n\n cellFormat = (row, col) ->\n fid = formatMap.get(\"#{row + 1},#{col + 1}\")\n if fid isnt undefined then formatRegistry[fid] else colFormatCache[col]\n\n _ready := false\n scrollTop := 0\n containerHeight := 0\n activeRow := -1\n activeCol := -1\n anchorRow := -1\n anchorCol := -1\n selecting := false\n editing := false\n editValue := ''\n _enterCommit = false\n dataVersion := 0\n sortKeys := []\n\n cmp = (a, b) -> (a > b) - (a < b)\n\n sortIndex ~=\n dataVersion\n len = @data.length\n idx = new Array(len)\n i = 0\n while i < len\n idx[i] = i\n i++\n if sortKeys.length > 0\n idx.sort (a, b) ->\n ki = 0\n while ki < sortKeys.length\n sk = sortKeys[ki]\n va = @data[a][@columns[sk.col].key]\n vb = @data[b][@columns[sk.col].key]\n result = if sk.dir is 'asc' then cmp(va, vb) else cmp(vb, va)\n if result isnt 0 then return result\n ki++\n a - b\n idx\n\n sortInfo ~=\n arrows = new Array(@columns.length).fill('')\n ranks = new Array(@columns.length).fill(0)\n ki = 0\n while ki < sortKeys.length\n sk = sortKeys[ki]\n arrows[sk.col] = if sk.dir is 'asc' then \" ↓\" else \" ↑\"\n ranks[sk.col] = ki + 1\n ki++\n { arrows, ranks }\n\n cellRef ~= if activeRow >= 0 and activeCol >= 0 then \"R#{activeRow + 1}:C#{activeCol + 1}\" else ''\n totalRows ~= @data.length\n startRow ~= Math.max(0, Math.floor(scrollTop / @rowHeight) - @overscan)\n endRow ~= Math.min(totalRows - 1, Math.ceil((scrollTop + containerHeight) / @rowHeight) + @overscan)\n offsetY ~= startRow * @rowHeight\n bottomSpace ~= Math.max(0, (totalRows - endRow - 1) * @rowHeight)\n\n # --------------------------------------------------------------------------\n # DOM Recycling — pooled <tr>/<td> elements, imperative updates on scroll\n # --------------------------------------------------------------------------\n #\n # Instead of letting the reactive render loop recreate row components on\n # every scroll frame, we maintain a fixed pool of <tr> elements and update\n # cell textContent directly. This eliminates DOM allocation and GC pressure\n # during fast scrolling — the most performance-critical path in the grid.\n\n _trPool = []\n _topSpacer = null\n _botSpacer = null\n _selBorder = null\n\n _createSpacer: ->\n tr = document.createElement('tr')\n tr.className = 'spacer'\n td = document.createElement('td')\n td.style.padding = '0'\n td.style.border = 'none'\n td.style.lineHeight = '0'\n td.style.fontSize = '0'\n tr.appendChild(td)\n tr\n\n _ensurePool: (needed, numCols) ->\n while _trPool.length < needed\n tr = document.createElement('tr')\n ci = 0\n while ci < numCols\n td = document.createElement('td')\n tr.appendChild(td)\n ci++\n _trPool.push(tr)\n # Add cells if column count grew\n for tr in _trPool\n while tr.children.length < numCols\n tr.appendChild(document.createElement('td'))\n\n # Effect: update tbody rows from pool on every viewport/data change\n ~>\n return unless _ready\n tbody = @_body\n return unless tbody\n numCols = @columns.length\n count = endRow - startRow + 1\n count = 0 if count < 0\n\n @_ensurePool(count, numCols)\n\n _topSpacer.children[0].colSpan = numCols\n _topSpacer.children[0].style.height = \"#{offsetY}px\"\n _botSpacer.children[0].colSpan = numCols\n _botSpacer.children[0].style.height = \"#{bottomSpace}px\"\n\n # Detach all current rows (fast: replaceChildren rebuilds in one\n # reflow instead of N removeChild calls)\n rows = [_topSpacer]\n\n i = 0\n n = startRow\n while n <= endRow\n if n >= 0 and n < totalRows\n tr = _trPool[i]\n di = sortIndex[n]\n row = @data[di]\n\n # Stripe class\n if @striped and n % 2 is 1\n tr.className = 'even'\n else\n tr.className = ''\n\n # Update cells — only textContent, the cheapest DOM mutation\n ci = 0\n while ci < numCols\n td = tr.children[ci]\n c = @columns[ci]\n v = row[c.key]\n fmt = cellFormat(di, ci)\n display = if fmt then fmt.format(v) else v\n\n td.style.textAlign = c.align or 'left'\n\n if c.type is 'checkbox'\n # Checkbox cells need an <input> — reuse if present\n inp = td.firstChild\n unless inp and inp.tagName is 'INPUT'\n td.textContent = ''\n inp = document.createElement('input')\n inp.type = 'checkbox'\n inp.className = 'cell-checkbox'\n inp.style.pointerEvents = 'none'\n inp.style.margin = '0'\n inp.style.verticalAlign = 'middle'\n td.appendChild(inp)\n inp.checked = !!v\n else\n # Plain text — fastest path\n if td.firstChild and td.firstChild.nodeType is 3\n td.firstChild.nodeValue = if display? then String(display) else ''\n else\n td.textContent = if display? then String(display) else ''\n\n ci++\n rows.push(tr)\n i++\n n++\n\n rows.push(_botSpacer)\n tbody.replaceChildren(...rows)\n\n # Selection overlay — applies data-* attributes to active/selected cells\n ~>\n return unless _ready\n tbody = @_body\n return unless tbody\n trs = tbody.querySelectorAll('tr:not(.spacer)')\n\n for el in tbody.querySelectorAll('td[data-active], td[data-selected]')\n delete el.dataset.active\n delete el.dataset.selected\n el.style.borderColor = ''\n\n unless _selBorder\n _selBorder = document.createElement('div')\n _selBorder.style.cssText = 'position:absolute;pointer-events:none;border:0;outline:1px solid #3b82f6;outline-offset:-1px;z-index:1;display:none'\n @_container.appendChild(_selBorder)\n\n if anchorRow >= 0 and activeRow >= 0 and anchorCol >= 0 and activeCol >= 0\n minR = Math.min(anchorRow, activeRow)\n maxR = Math.max(anchorRow, activeRow)\n minC = Math.min(anchorCol, activeCol)\n maxC = Math.max(anchorCol, activeCol)\n multi = minR isnt maxR or minC isnt maxC\n r = Math.max(minR, startRow)\n while r <= Math.min(maxR, endRow)\n tr = trs[r - startRow]\n if tr\n c = minC\n while c <= maxC\n td = tr.children[c]\n if td\n td.dataset.selected = ''\n td.style.borderColor = '#bfdbfe'\n c++\n r++\n\n if multi and not selecting\n topTr = trs[Math.max(minR, startRow) - startRow]\n botTr = trs[Math.min(maxR, endRow) - startRow]\n if topTr and botTr\n topTd = topTr.children[minC]\n botTd = botTr.children[maxC]\n if topTd and botTd\n cRect = @_container.getBoundingClientRect()\n tl = topTd.getBoundingClientRect()\n br = botTd.getBoundingClientRect()\n _selBorder.style.top = \"#{tl.top - cRect.top + @_container.scrollTop}px\"\n _selBorder.style.left = \"#{tl.left - cRect.left + @_container.scrollLeft}px\"\n _selBorder.style.width = \"#{br.right - tl.left}px\"\n _selBorder.style.height = \"#{br.bottom - tl.top}px\"\n _selBorder.style.display = 'block'\n else\n _selBorder.style.display = 'none'\n else\n _selBorder.style.display = 'none'\n else\n _selBorder.style.display = 'none'\n else\n _selBorder.style.display = 'none'\n\n cursorRow = if selecting then anchorRow else activeRow\n cursorCol = if selecting then anchorCol else activeCol\n if cursorRow >= startRow and cursorRow <= endRow and cursorCol >= 0\n tr = trs[cursorRow - startRow]\n if tr\n td = tr.children[cursorCol]\n if td\n td.dataset.active = ''\n\n _removeEditor: ->\n @_container.querySelector('.rip-grid-editor')?.remove()\n\n openEditor: (initial, cursorEnd) ->\n if activeRow < 0 or activeCol < 0 or editing\n return\n col = @columns[activeCol]\n if col.type is 'checkbox'\n @data[sortIndex[activeRow]][col.key] = not @data[sortIndex[activeRow]][col.key]\n dataVersion++\n return\n val = @data[sortIndex[activeRow]][col.key]\n orig = String(val ?? '')\n if initial isnt undefined and col.type isnt 'select'\n editValue = initial\n else\n editValue = orig\n editing = true\n @_removeEditor()\n el = undefined\n if col.type is 'select'\n el = document.createElement('select')\n el.className = 'rip-grid-editor'\n el.style.borderRadius = '0'\n for opt in (col.source or [])\n o = document.createElement('option')\n o.value = opt\n o.textContent = opt\n o.selected = (opt is editValue)\n el.appendChild(o)\n el.addEventListener 'change', (e) =>\n editValue = e.target.value\n @commitEditor()\n el.addEventListener 'keydown', (e) => @_editorKeydown(e)\n else\n el = document.createElement('input')\n el.className = 'rip-grid-editor'\n el.type = 'text'\n el.value = editValue\n align = col.align or 'left'\n el.style.textAlign = align\n el.addEventListener 'input', (e) =>\n editValue = e.target.value\n el.addEventListener 'keydown', (e) => @_editorKeydown(e)\n @_container.appendChild(el)\n requestAnimationFrame =>\n trs = @_body.querySelectorAll('tr:not(.spacer)')\n tr = trs[activeRow - startRow]\n if tr\n td = tr.children[activeCol]\n if td\n tdRect = td.getBoundingClientRect()\n cRect = @_container.getBoundingClientRect()\n el.style.top = \"#{tdRect.top - cRect.top + @_container.scrollTop}px\"\n el.style.left = \"#{tdRect.left - cRect.left + @_container.scrollLeft}px\"\n el.style.width = \"#{tdRect.width}px\"\n el.style.height = \"#{tdRect.height}px\"\n if el.selectionStart isnt undefined\n el.selectionStart = el.value.length\n el.selectionEnd = el.value.length\n el.focus()\n\n commitEditor: ->\n if editing\n di = sortIndex[activeRow]\n col = @columns[activeCol]\n oldVal = @data[di][col.key]\n val = editValue\n fmt = cellFormat(di, activeCol)\n if fmt and fmt.parse\n val = fmt.parse(val)\n if @beforeEdit\n val = @beforeEdit(di, activeCol, oldVal, val)\n if val is false then return @cancelEditor()\n @data[di][col.key] = val\n dataVersion++\n editing = false\n @_removeEditor()\n @_container.focus() if @_container\n if @afterEdit\n @afterEdit(di, activeCol, oldVal, val)\n\n cancelEditor: ->\n editing = false\n @_removeEditor()\n anchorRow = activeRow\n anchorCol = activeCol\n @_container.focus() if @_container\n\n _editorKeydown: (e) ->\n numCols = @columns.length\n switch e.key\n when 'Enter'\n e.preventDefault()\n e.stopPropagation()\n @commitEditor()\n _enterCommit = true\n when 'Tab'\n e.preventDefault()\n e.stopPropagation()\n @commitEditor()\n if e.shiftKey\n if activeCol > 0\n activeCol = activeCol - 1\n else if activeRow > 0\n activeRow = activeRow - 1\n activeCol = numCols - 1\n @scrollToRow(activeRow)\n else\n if activeCol < numCols - 1\n activeCol = activeCol + 1\n else if activeRow < totalRows - 1\n activeRow = activeRow + 1\n activeCol = 0\n @scrollToRow(activeRow)\n anchorRow = activeRow\n anchorCol = activeCol\n when 'Escape'\n e.preventDefault()\n e.stopPropagation()\n @cancelEditor()\n\n _resizeStart: (e) ->\n e.stopPropagation()\n e.preventDefault()\n th = e.target.closest('th')\n if not th then return\n ci = th.cellIndex\n startX = e.clientX\n startW = @columns[ci].width\n handle = e.target\n handle.classList.add('active')\n colEl = @_container.querySelector(\"colgroup\").children[ci]\n onMove = (ev) =>\n w = Math.max(40, startW + ev.clientX - startX)\n colEl.style.width = \"#{w}px\"\n onUp = (ev) =>\n document.removeEventListener('mousemove', onMove)\n document.removeEventListener('mouseup', onUp)\n handle.classList.remove('active')\n w = Math.max(40, startW + ev.clientX - startX)\n @columns = @columns.map (c, i) -> if i is ci then Object.assign({}, c, { width: w }) else c\n document.addEventListener('mousemove', onMove)\n document.addEventListener('mouseup', onUp)\n\n _headerClick: (e) ->\n if e.target.classList.contains('resize-handle') then return\n th = e.target.closest('th')\n if not th then return\n ci = th.cellIndex\n if e.shiftKey\n existing = sortKeys.findIndex (sk) -> sk.col is ci\n if existing >= 0\n if sortKeys[existing].dir is 'asc'\n sortKeys = sortKeys.map (sk, i) -> if i is existing then { col: sk.col, dir: 'desc' } else sk\n else if sortKeys.length > 1\n sortKeys = sortKeys.map (sk, i) -> if i is existing then { col: sk.col, dir: 'asc' } else sk\n else\n sortKeys = sortKeys.filter (_, i) -> i isnt existing\n else\n sortKeys = sortKeys.concat({ col: ci, dir: 'asc' })\n else\n if sortKeys.length is 1 and sortKeys[0].col is ci\n if sortKeys[0].dir is 'asc'\n sortKeys = [{ col: ci, dir: 'desc' }]\n else\n sortKeys = []\n else\n sortKeys = [{ col: ci, dir: 'asc' }]\n dataVersion++\n\n onDblclick: (e) ->\n td = e.target.closest('td')\n if td and not td.parentElement.classList.contains('spacer')\n @openEditor()\n\n onScroll: (e) ->\n @commitEditor() if editing\n @_nextST = e.currentTarget.scrollTop\n if not @_rafPending\n @_rafPending = true\n requestAnimationFrame =>\n scrollTop = @_nextST\n @_rafPending = false\n\n scrollToRow: (row) ->\n el = @_container\n if el\n top = row * @rowHeight\n if top < el.scrollTop\n el.scrollTop = top\n else if top + @rowHeight + @headerHeight > el.scrollTop + containerHeight\n el.scrollTop = top + @rowHeight + @headerHeight - containerHeight\n\n onMousedown: (e) ->\n if e.button isnt 0\n return\n if e.target.classList.contains('rip-grid-editor')\n return\n @commitEditor() if editing\n td = e.target.closest('td')\n if td and not td.parentElement.classList.contains('spacer')\n e.preventDefault()\n @_container.focus() if @_container\n selecting = true\n el = @_container\n rect = el.getBoundingClientRect()\n ri = Math.floor((e.clientY - rect.top + el.scrollTop - @headerHeight) / @rowHeight)\n if ri >= 0 and ri < totalRows\n activeRow = ri\n activeCol = td.cellIndex\n if not e.shiftKey\n anchorRow = ri\n anchorCol = td.cellIndex\n\n onMouseup: (e) ->\n if selecting\n r = anchorRow; c = anchorCol\n anchorRow = activeRow; anchorCol = activeCol\n activeRow = r; activeCol = c\n selecting = false\n\n onMousemove: (e) ->\n if not selecting or e.buttons isnt 1\n return\n el = @_container\n rect = el.getBoundingClientRect()\n ri = Math.floor((e.clientY - rect.top + el.scrollTop - @headerHeight) / @rowHeight)\n ri = Math.max(0, Math.min(totalRows - 1, ri))\n td = e.target.closest('td')\n ci = if td and not td.parentElement.classList.contains('spacer') then td.cellIndex else activeCol\n activeRow = ri\n activeCol = ci\n @scrollToRow(ri)\n\n _jumpToEdge: (row, col, dr, dc) ->\n key = @columns[col]?.key\n return { row, col } unless key\n cur = @data[sortIndex[row]]?[key]\n empty = not cur? or cur is ''\n r = row + dr\n c = col + dc\n while r >= 0 and r < totalRows and c >= 0 and c < @columns.length\n k = @columns[c].key\n v = @data[sortIndex[r]]?[k]\n ve = not v? or v is ''\n if empty\n return { row: r, col: c } unless ve\n else\n if ve then return { row: r - dr, col: c - dc }\n r += dr\n c += dc\n row: Math.max(0, Math.min(totalRows - 1, r - dr))\n col: Math.max(0, Math.min(@columns.length - 1, c - dc))\n\n onKeydown: (e) ->\n if editing\n return\n numCols = @columns.length\n key = e.key\n _enterCommit = false if key isnt 'Enter'\n switch key\n when 'ArrowDown'\n e.preventDefault()\n if activeRow < 0\n activeRow = 0\n activeCol = 0 if activeCol < 0\n else if e.ctrlKey or e.metaKey\n pos = @_jumpToEdge(activeRow, activeCol, 1, 0)\n activeRow = pos.row\n else if activeRow < totalRows - 1\n activeRow = activeRow + 1\n @scrollToRow(activeRow)\n when 'ArrowUp'\n e.preventDefault()\n if e.ctrlKey or e.metaKey\n pos = @_jumpToEdge(activeRow, activeCol, -1, 0)\n activeRow = pos.row\n activeCol = 0 if activeCol < 0\n else if activeRow > 0\n activeRow = activeRow - 1\n @scrollToRow(activeRow)\n when 'ArrowRight'\n e.preventDefault()\n if activeCol < 0\n activeCol = 0\n activeRow = 0 if activeRow < 0\n else if e.ctrlKey or e.metaKey\n pos = @_jumpToEdge(activeRow, activeCol, 0, 1)\n activeCol = pos.col\n else if activeCol < numCols - 1\n activeCol = activeCol + 1\n when 'ArrowLeft'\n e.preventDefault()\n if e.ctrlKey or e.metaKey\n pos = @_jumpToEdge(activeRow, activeCol, 0, -1)\n activeCol = pos.col\n activeRow = 0 if activeRow < 0\n else if activeCol > 0\n activeCol = activeCol - 1\n when 'Home'\n e.preventDefault()\n if e.ctrlKey or e.metaKey\n activeRow = 0\n activeCol = 0\n @scrollToRow(0)\n else\n activeRow = 0 if activeRow < 0\n activeCol = 0\n when 'End'\n e.preventDefault()\n if e.ctrlKey or e.metaKey\n activeRow = totalRows - 1\n activeCol = numCols - 1\n @scrollToRow(activeRow)\n else\n activeRow = 0 if activeRow < 0\n activeCol = numCols - 1\n when 'Tab'\n e.preventDefault()\n if activeRow < 0 or activeCol < 0\n activeRow = 0\n activeCol = 0\n else if e.shiftKey\n if activeCol > 0\n activeCol = activeCol - 1\n else if activeRow > 0\n activeRow = activeRow - 1\n activeCol = numCols - 1\n @scrollToRow(activeRow)\n else\n if activeCol < numCols - 1\n activeCol = activeCol + 1\n else if activeRow < totalRows - 1\n activeRow = activeRow + 1\n activeCol = 0\n @scrollToRow(activeRow)\n when 'Enter'\n e.preventDefault()\n if activeRow < 0 or activeCol < 0\n activeRow = 0\n activeCol = 0\n else if _enterCommit\n _enterCommit = false\n if e.shiftKey\n if activeRow > 0\n activeRow = activeRow - 1\n anchorRow = activeRow\n @scrollToRow(activeRow)\n else\n if activeRow < totalRows - 1\n activeRow = activeRow + 1\n anchorRow = activeRow\n @scrollToRow(activeRow)\n @openEditor()\n return\n else\n @openEditor()\n return\n when 'F2'\n e.preventDefault()\n if activeRow >= 0 and activeCol >= 0\n @openEditor(undefined, true)\n return\n when 'PageDown'\n e.preventDefault()\n rows = Math.floor(containerHeight / @rowHeight)\n activeRow = Math.min(totalRows - 1, activeRow + rows)\n activeCol = 0 if activeCol < 0\n @scrollToRow(activeRow)\n when 'PageUp'\n e.preventDefault()\n rows = Math.floor(containerHeight / @rowHeight)\n activeRow = Math.max(0, activeRow - rows)\n activeCol = 0 if activeCol < 0\n @scrollToRow(activeRow)\n when 'Escape'\n if anchorRow isnt activeRow or anchorCol isnt activeCol\n anchorRow = activeRow\n anchorCol = activeCol\n else\n activeRow = -1\n activeCol = -1\n anchorRow = -1\n anchorCol = -1\n return\n when ' '\n if activeRow >= 0 and activeCol >= 0\n col = @columns[activeCol]\n if col.type is 'checkbox'\n e.preventDefault()\n @data[sortIndex[activeRow]][col.key] = not @data[sortIndex[activeRow]][col.key]\n dataVersion++\n return\n when 'Delete', 'Backspace'\n if activeRow >= 0 and activeCol >= 0 and not e.ctrlKey and not e.metaKey\n e.preventDefault()\n col = @columns[activeCol]\n if col.type is 'checkbox'\n @data[sortIndex[activeRow]][col.key] = false\n else\n fmt = cellFormat(sortIndex[activeRow], activeCol)\n @data[sortIndex[activeRow]][col.key] = if fmt and fmt.parse then fmt.parse('') else ''\n dataVersion++\n return\n when 'a'\n if e.ctrlKey or e.metaKey\n e.preventDefault()\n anchorRow = 0\n anchorCol = 0\n activeRow = totalRows - 1\n activeCol = numCols - 1\n return\n when 'c'\n if (e.ctrlKey or e.metaKey) and activeRow >= 0 and activeCol >= 0\n e.preventDefault()\n @copySelection()\n return\n when 'v'\n if e.ctrlKey or e.metaKey\n e.preventDefault()\n @pasteAtActive()\n return\n when 'x'\n if (e.ctrlKey or e.metaKey) and activeRow >= 0 and activeCol >= 0\n e.preventDefault()\n @cutSelection()\n return\n else\n if key.length is 1 and not e.ctrlKey and not e.metaKey and not e.altKey\n if activeRow >= 0 and activeCol >= 0\n ct = @columns[activeCol].type\n if ct isnt 'checkbox' and ct isnt 'select'\n e.preventDefault()\n @openEditor(key)\n return\n\n extending = e.shiftKey and key isnt 'Tab' and key isnt 'Enter'\n if not extending\n anchorRow = activeRow\n anchorCol = activeCol\n\n # --------------------------------------------------------------------------\n # Clipboard — copy, cut, paste via navigator.clipboard (TSV format)\n # --------------------------------------------------------------------------\n\n _selectionBounds: ->\n minR = Math.min(anchorRow, activeRow)\n maxR = Math.max(anchorRow, activeRow)\n minC = Math.min(anchorCol, activeCol)\n maxC = Math.max(anchorCol, activeCol)\n { minR, maxR, minC, maxC }\n\n _selectionToTSV: ->\n { minR, maxR, minC, maxC } = @_selectionBounds()\n lines = []\n r = minR\n while r <= maxR\n di = sortIndex[r]\n row = @data[di]\n cells = []\n c = minC\n while c <= maxC\n v = row[@columns[c].key]\n s = if v? then String(v) else ''\n if s.includes('\\t') or s.includes('\\n') or s.includes('\"')\n s = '\"' + s.replace(/\"/g, '\"\"') + '\"'\n cells.push(s)\n c++\n lines.push(cells.join('\\t'))\n r++\n lines.join('\\n')\n\n _parseTSV: (text) ->\n rows = []\n lines = text.replace(/\\r\\n/g, '\\n').replace(/\\r/g, '\\n').split('\\n')\n for line, li in lines\n continue if line is '' and li is lines.length - 1\n if line.includes('\"')\n cells = []\n i = 0\n while i < line.length\n if line[i] is '\"'\n j = i + 1\n val = ''\n while j < line.length\n if line[j] is '\"'\n if line[j + 1] is '\"'\n val += '\"'\n j += 2\n else\n j++\n break\n else\n val += line[j]\n j++\n cells.push(val)\n i = j + 1\n else\n j = line.indexOf('\\t', i)\n if j is -1\n cells.push(line.slice(i))\n break\n else\n cells.push(line.slice(i, j))\n i = j + 1\n rows.push(cells)\n else\n rows.push(line.split('\\t'))\n rows\n\n copySelection: ->\n return if activeRow < 0 or activeCol < 0\n tsv = @_selectionToTSV()\n navigator.clipboard.writeText(tsv) if navigator.clipboard\n\n cutSelection: ->\n return if activeRow < 0 or activeCol < 0\n @copySelection()\n { minR, maxR, minC, maxC } = @_selectionBounds()\n r = minR\n while r <= maxR\n di = sortIndex[r]\n c = minC\n while c <= maxC\n col = @columns[c]\n if col.type is 'checkbox'\n @data[di][col.key] = false\n else\n fmt = cellFormat(di, c)\n @data[di][col.key] = if fmt and fmt.parse then fmt.parse('') else ''\n c++\n r++\n dataVersion++\n\n pasteAtActive: ->\n return if activeRow < 0 or activeCol < 0\n return unless navigator.clipboard\n navigator.clipboard.readText().then (text) =>\n return unless text\n rows = @_parseTSV(text)\n return unless rows.length\n numCols = @columns.length\n r = 0\n while r < rows.length\n ri = activeRow + r\n break if ri >= totalRows\n di = sortIndex[ri]\n c = 0\n while c < rows[r].length\n ci = activeCol + c\n break if ci >= numCols\n col = @columns[ci]\n val = rows[r][c]\n if col.type is 'checkbox'\n @data[di][col.key] = val is 'true' or val is '1' or val is 'yes'\n else\n fmt = cellFormat(di, ci)\n @data[di][col.key] = if fmt and fmt.parse then fmt.parse(val) else val\n c++\n r++\n dataVersion++\n\n # Public API\n getCell: (row, col) -> @data[row][@columns[col].key]\n setCell: (row, col, value) -> @data[row][@columns[col].key] = value; dataVersion++\n getData: -> @data\n setData: (d) -> @data = d; dataVersion++\n sort: (col, dir) -> sortKeys = if dir then [{ col, dir }] else []; dataVersion++\n\n mounted: ->\n _topSpacer = @_createSpacer()\n _botSpacer = @_createSpacer()\n _ready = true\n if @_container\n handler = (entries) =>\n for entry in entries\n containerHeight = entry.contentRect.height\n @_resizeObs = new ResizeObserver(handler)\n @_resizeObs.observe(@_container)\n\n unmounted: ->\n if @_resizeObs\n @_resizeObs.disconnect()\n\n render\n div.grid-container ref: \"_container\", role: \"grid\", tabIndex: 0,\n $editing: editing?!,\n $selecting: selecting?!\n table.rip-grid\n colgroup\n for c in @columns\n col style: \"width: #{c.width}px\"\n thead\n tr @click: @_headerClick\n for c, ci in @columns\n th style: \"text-align: #{c.align or 'left'}; cursor: pointer\",\n $sorted: (if sortInfo.arrows[ci] then (if sortInfo.arrows[ci].includes('↓') then 'asc' else 'desc') else undefined)\n = c.title\n span.sort-arrow\n sortInfo.arrows[ci]\n if sortKeys.length > 1 and sortInfo.ranks[ci] > 0\n span.sort-badge\n \"#{sortInfo.ranks[ci]}\"\n span.resize-handle @mousedown: @_resizeStart\n tbody ref: \"_body\"\n",
5
+ "_pkg/ui/preview-card.rip": "# PreviewCard — accessible headless hover preview card\n#\n# Shows a floating card on hover/focus of a trigger element. Dismisses\n# on mouse leave or blur. Uses native `popover=\"hint\"` for top-layer behavior.\n# Ships zero CSS.\n#\n# Usage:\n# PreviewCard delay: 400\n# a $trigger: true, href: \"/user/42\", \"View Profile\"\n# div $content: true\n# p \"User details here...\"\n\nexport PreviewCard = component\n @delay := 400\n @closeDelay := 200\n\n open := false\n _ready := false\n _openTimer := null\n _closeTimer := null\n _id =! \"pc-#{Math.random().toString(36).slice(2, 8)}\"\n\n beforeUnmount: ->\n clearTimeout _openTimer if _openTimer\n clearTimeout _closeTimer if _closeTimer\n\n mounted: ->\n _ready = true\n trigger = @_root?.querySelector('[data-trigger]')\n floating = @_root?.querySelector('[data-content]')\n return unless trigger and floating\n floating.id = _id\n floating.setAttribute 'popover', 'hint'\n trigger.setAttribute 'aria-controls', _id\n trigger.setAttribute 'aria-expanded', false\n trigger.addEventListener 'mouseenter', =>\n clearTimeout _closeTimer if _closeTimer\n _openTimer = setTimeout (=> open = true), @delay\n trigger.addEventListener 'mouseleave', =>\n clearTimeout _openTimer if _openTimer\n _closeTimer = setTimeout (=> open = false), @closeDelay\n @_applyPlacement()\n trigger.addEventListener 'focus', =>\n clearTimeout _closeTimer if _closeTimer\n _openTimer = setTimeout (=> open = true; @_applyPlacement()), @delay\n trigger.addEventListener 'blur', =>\n clearTimeout _openTimer if _openTimer\n _closeTimer = setTimeout (=> open = false), @closeDelay\n\n _applyPlacement: ->\n trigger = @_root?.querySelector('[data-trigger]')\n floating = @_root?.querySelector('[data-content]')\n ARIA.position trigger, floating, placement: 'bottom start', offset: 4\n\n ~>\n return unless _ready\n trigger = @_root?.querySelector('[data-trigger]')\n floating = @_root?.querySelector('[data-content]')\n return unless floating and trigger\n trigger.setAttribute 'aria-expanded', !!open\n floating.hidden = not open\n if open then floating.removeAttribute 'aria-hidden' else floating.setAttribute 'aria-hidden', 'true'\n @_applyPlacement()\n if open then floating.setAttribute('data-open', '') else floating.removeAttribute('data-open')\n ARIA.bindPopover open, (=> floating), ((isOpen) => open = isOpen), (=> trigger)\n if open\n onEnter = => clearTimeout _closeTimer if _closeTimer\n onLeave = => _closeTimer = setTimeout (=> open = false), @closeDelay\n floating.addEventListener 'mouseenter', onEnter\n floating.addEventListener 'mouseleave', onLeave\n return ->\n floating.removeEventListener 'mouseenter', onEnter\n floating.removeEventListener 'mouseleave', onLeave\n\n render\n div ref: \"_root\"\n slot\n",
6
+ "_pkg/ui/field.rip": "# Field — accessible headless form field wrapper\n#\n# Associates a label, description, and error message with a form control.\n# Generates linked IDs for aria-labelledby/describedby/errormessage.\n# Ships zero CSS.\n#\n# Usage:\n# Field label: \"Email\", error: errors.email\n# Input value <=> email, type: \"email\"\n\nexport Field = component\n @label := \"\"\n @description := \"\"\n @error := \"\"\n @disabled := false\n @required := false\n\n _id =! \"fld-#{Math.random().toString(36).slice(2, 8)}\"\n\n mounted: ->\n ctrl = @_root?.querySelector('input, select, textarea, button, [role]')\n if ctrl\n ctrl.setAttribute 'aria-labelledby', \"#{_id}-label\" if @label\n ctrl.setAttribute 'aria-describedby', \"#{_id}-desc\" if @description\n ctrl.setAttribute 'aria-errormessage', \"#{_id}-err\" if @error\n ctrl.setAttribute 'aria-invalid', true if @error\n ctrl.setAttribute 'aria-required', true if @required\n\n ~>\n ctrl = @_root?.querySelector('input, select, textarea, button, [role]')\n return unless ctrl\n if @error\n ctrl.setAttribute 'aria-invalid', true\n ctrl.setAttribute 'aria-errormessage', \"#{_id}-err\"\n else\n ctrl.removeAttribute 'aria-invalid'\n ctrl.removeAttribute 'aria-errormessage'\n\n render\n div $disabled: @disabled?!, $invalid: @error?!\n if @label\n label id: \"#{_id}-label\", $label: true\n @label\n if @required\n span $required: true, aria-hidden: \"true\"\n \" *\"\n slot\n if @description and not @error\n div id: \"#{_id}-desc\", $description: true\n @description\n if @error\n div id: \"#{_id}-err\", role: \"alert\", $error: true\n @error\n",
7
+ "_pkg/ui/toolbar.rip": "# Toolbar — accessible headless toolbar\n#\n# Groups interactive controls with roving tabindex keyboard navigation.\n# Arrow keys move focus between focusable children. Ships zero CSS.\n#\n# Usage:\n# Toolbar\n# Button @click: save, \"Save\"\n# Button @click: undo, \"Undo\"\n# Separator orientation: \"vertical\"\n# Toggle pressed <=> isBold, \"Bold\"\n\nexport Toolbar = component\n @orientation := \"horizontal\"\n @label := \"\"\n\n _getFocusable: ->\n return [] unless @_root\n Array.from(@_root.querySelectorAll('button, [tabindex], input, select, textarea')).filter (el) ->\n not el.disabled and el.offsetParent isnt null\n\n onKeydown: (e) ->\n els = @_getFocusable()\n return unless els.length\n focused = els.indexOf(document.activeElement)\n return if focused < 0\n len = els.length\n ARIA.rovingNav e, {\n next: => els[(focused + 1) %% len]?.focus()\n prev: => els[(focused - 1) %% len]?.focus()\n first: => els[0]?.focus()\n last: => els[len - 1]?.focus()\n }, @orientation\n\n render\n div role: \"toolbar\", aria-label: @label or undefined, aria-orientation: @orientation\n $orientation: @orientation\n slot\n",
8
+ "_pkg/ui/combobox.rip": "# Combobox — accessible headless combobox (input + filterable listbox)\n#\n# Keyboard: ArrowDown/Up to navigate, Enter to select, Escape to close,\n# typing filters the list via the @filter callback.\n#\n# Exposes $open on the wrapper, $highlighted on options.\n# Ships zero CSS — style entirely via attribute selectors in your stylesheet.\n#\n# Usage:\n# Combobox query <=> searchText, items: fruits, @select: handleSelect, @filter: filterFn\n\nexport Combobox = component\n @query := \"\"\n @items := []\n @placeholder := \"Search...\"\n @disabled := false\n @autoHighlight := true\n\n open := false\n highlightedIndex := -1\n _ready := false\n _popupGuard =! ARIA.popupGuard()\n _listId =! \"cb-#{Math.random().toString(36).slice(2, 8)}\"\n\n getItems: ->\n return [] unless @_list\n Array.from(@_list.querySelectorAll('[role=\"option\"]'))\n\n _scrollToItem: ->\n @getItems()[highlightedIndex]?.scrollIntoView({ block: 'nearest' })\n\n clear: ->\n @query = ''\n highlightedIndex = -1\n @emit 'filter', ''\n\n onInput: (e) ->\n @query = e.target.value\n open = true\n highlightedIndex = if @autoHighlight and @items.length > 0 then 0 else -1\n @emit 'filter', @query\n\n onFocusin: ->\n return unless _popupGuard.canOpen()\n @openMenu()\n\n onInputClick: ->\n return if @disabled or open\n @openMenu(true)\n\n onFocusout: ->\n setTimeout => @close(false, true) unless @_content?.contains(document.activeElement), 0\n\n openMenu: (force = false) ->\n return unless force or _popupGuard.canOpen()\n open = true\n highlightedIndex = -1\n @_input?.focus()\n\n close: (restoreFocus = true, blockOpen = false) ->\n open = false\n highlightedIndex = -1\n _popupGuard.block() if blockOpen\n @_input?.focus() if restoreFocus\n\n mounted: ->\n _ready = true\n\n _applyPlacement: ->\n ARIA.position @_content, @_list, placement: 'bottom start', offset: 2, matchWidth: true\n\n isDisabled: (item) -> item?.hasAttribute?('data-disabled')\n\n selectIndex: (idx) ->\n item = @getItems()[idx]\n return unless item\n return if @isDisabled(item)\n val = item.dataset.value ?? item.textContent.trim()\n @query = val\n @emit 'select', val\n @close(true, true)\n\n _nextEnabled: (from, dir) ->\n opts = @getItems()\n len = opts.length\n i = from\n loop len\n i = (i + dir) %% len\n return i unless @isDisabled(opts[i])\n from\n\n _onKeydown: (e) ->\n len = @getItems().length\n ARIA.listNav e,\n next: => @openMenu() unless open; highlightedIndex = @_nextEnabled(highlightedIndex, 1); @_scrollToItem()\n prev: => @openMenu() unless open; highlightedIndex = @_nextEnabled(highlightedIndex, -1); @_scrollToItem()\n first: => highlightedIndex = 0; @_scrollToItem()\n last: => highlightedIndex = len - 1; @_scrollToItem()\n select: => if highlightedIndex >= 0 then @selectIndex(highlightedIndex) else if len is 1 then @selectIndex(0)\n dismiss: => if open then @close() else @query = ''\n tab: => @close(false, true)\n\n ~>\n return unless _ready\n if @_list\n @_applyPlacement()\n # ARIA.combine: both helpers register listeners that need cleanup.\n # Without combine, only the LAST expression's value is taken as the\n # effect's cleanup, so the bindPopover `toggle` listener leaked on\n # every effect re-run.\n ARIA.combine(\n ARIA.bindPopover open, (=> @_list), ((isOpen) => open = isOpen), (=> @_input)\n ARIA.popupDismiss open, (=> @_list), (=> @close(false, true)), [=> @_input, => @_content]\n )\n\n render\n . ref: \"_content\", $open: open?!\n\n # Text input\n . style: \"position:relative;display:inline-flex;align-items:center\"\n input ref: \"_input\", role: \"combobox\"\n type: \"text\"\n autocomplete: \"off\"\n aria-expanded: !!open\n aria-haspopup: \"listbox\"\n aria-autocomplete: \"list\"\n aria-controls: if open then _listId else undefined\n aria-activedescendant: if highlightedIndex >= 0 then \"#{_listId}-#{highlightedIndex}\" else undefined\n $disabled: @disabled?!\n disabled: @disabled\n placeholder: @placeholder\n value: @query\n @input: @onInput\n @click: @onInputClick\n @keydown: @_onKeydown\n if @query\n button type: \"button\", aria-label: \"Clear\", $clear: true, @click: @clear\n \"✕\"\n\n # Listbox\n div ref: \"_list\"\n id: _listId\n role: \"listbox\"\n popover: \"auto\"\n hidden: not open\n aria-hidden: (open ? undefined : \"true\")\n style: \"position:fixed;margin:0;inset:auto\"\n $open: open?!\n for item, idx in @items\n div role: \"option\", tabIndex: -1, id: \"#{_listId}-#{idx}\"\n $value: item\n $highlighted: (idx is highlightedIndex)?!\n @click: (=> @selectIndex(idx))\n @mouseenter: (=> highlightedIndex = idx)\n \"#{item}\"\n if @items.length is 0 and @query\n div role: \"status\", aria-live: \"polite\", $empty: true\n \"No results\"\n",
9
+ "_pkg/ui/tooltip.rip": "# Tooltip — accessible headless tooltip with delay and positioning\n#\n# Shows on hover/focus with configurable delay. Uses aria-describedby and\n# native `popover=\"hint\"` for top-layer behavior.\n# Exposes $open, $entering, $exiting. Ships zero CSS.\n#\n# Usage:\n# Tooltip text: \"Helpful info\", placement: \"top\"\n# button \"Hover me\"\n\nlastCloseTime = 0\nGROUP_TIMEOUT = 400\n\nexport Tooltip = component\n @text := \"\"\n @placement := \"top\"\n @delay := 300\n @offset := 6\n @hoverable := false\n\n open := false\n entering := false\n exiting := false\n _showTimer := null\n _hideTimer := null\n _ready := false\n _id =! \"tip-#{Math.random().toString(36).slice(2, 8)}\"\n\n _applyPlacement: ->\n [side, align] = @placement.split('-')\n align ??= 'center'\n ARIA.position @_trigger, @_tip, placement: \"#{side} #{align}\", offset: @offset\n\n show: ->\n # Clear BOTH timers before scheduling a new show. mouseenter +\n # focusin can fire show() back-to-back; without clearing the\n # previous _showTimer the second schedule wins but the first one\n # still fires later, double-toggling state.\n clearTimeout _hideTimer if _hideTimer\n clearTimeout _showTimer if _showTimer\n _hideTimer = null\n delay = if (Date.now() - lastCloseTime) < GROUP_TIMEOUT then 0 else @delay\n _showTimer = setTimeout =>\n _showTimer = null\n open = true\n entering = true\n setTimeout =>\n entering = false\n , 0\n , delay\n\n hide: ->\n clearTimeout _showTimer if _showTimer\n clearTimeout _hideTimer if _hideTimer\n _showTimer = null\n exiting = true\n _hideTimer = setTimeout =>\n _hideTimer = null\n open = false\n exiting = false\n lastCloseTime = Date.now()\n , 150\n\n _cancelHide: ->\n clearTimeout _hideTimer if _hideTimer\n _hideTimer = null\n exiting = false\n\n beforeUnmount: ->\n clearTimeout _showTimer if _showTimer\n clearTimeout _hideTimer if _hideTimer\n _showTimer = null\n _hideTimer = null\n\n mounted: ->\n _ready = true\n\n ~>\n return unless _ready\n if @_tip\n @_tip.setAttribute 'popover', 'hint'\n @_applyPlacement()\n if open then @_tip.setAttribute('data-open', '') else @_tip.removeAttribute('data-open')\n ARIA.bindPopover open, (=> @_tip), ((isOpen) => open = isOpen), (=> @_trigger)\n\n render\n .\n div ref: \"_trigger\"\n aria-describedby: open ? _id : undefined\n @mouseenter: @show\n @mouseleave: @hide\n @focusin: @show\n @focusout: @hide\n slot\n\n div ref: \"_tip\"\n id: _id\n role: \"tooltip\"\n popover: \"hint\"\n hidden: not open\n aria-hidden: (open ? undefined : \"true\")\n style: \"position:fixed;margin:0;inset:auto\"\n $open: open?!\n $entering: entering?!\n $exiting: exiting?!\n $placement: @placement\n @mouseenter: (=> @_cancelHide() if @hoverable)\n @mouseleave: (=> @hide() if @hoverable)\n @text\n",
10
+ "_pkg/ui/label.rip": "# Label — accessible headless form label\n#\n# Standalone label element that associates with a form control via @for.\n# Companions Field but works independently. Ships zero CSS.\n#\n# Usage:\n# Label for: \"email-input\", \"Email address\"\n# Label required: true, \"Username\"\n\nexport Label = component\n @for := null\n @required := false\n\n render\n label for: @for?!, $required: @required?!\n slot\n",
11
+ "_pkg/ui/resizable.rip": "# Resizable — accessible headless resizable panels\n#\n# Container with draggable handles between panels for resizing.\n# Panel sizes are stored as percentages and exposed via CSS\n# custom properties. Place [data-handle] elements between [data-panel]\n# elements. Ships zero CSS.\n#\n# Usage:\n# Resizable\n# div $panel: true\n# p \"Left panel\"\n# div $handle: true\n# div $panel: true\n# p \"Right panel\"\n#\n# Resizable orientation: \"vertical\", minSize: 20\n# div $panel: true, \"Top\"\n# div $handle: true\n# div $panel: true, \"Bottom\"\n\nexport Resizable = component\n @orientation := \"horizontal\"\n @minSize := 10\n @maxSize := 90\n\n _ready := false\n _dragging = null\n _startPos = 0\n _startSizes = []\n sizes := []\n\n _panels: ->\n return [] unless @_root\n Array.from(@_root.querySelectorAll(':scope > [data-panel]') or [])\n\n _handles: ->\n return [] unless @_root\n Array.from(@_root.querySelectorAll(':scope > [data-handle]') or [])\n\n mounted: ->\n _ready = true\n panels = @_panels()\n count = panels.length\n if count and not sizes.length\n even = 100 / count\n sizes = Array.from {length: count}, -> even\n\n @_handles().forEach (handle, idx) =>\n handle.setAttribute 'role', 'separator'\n handle.setAttribute 'tabindex', '0'\n handle.addEventListener 'pointerdown', (e) => @_onPointerDown(idx, e)\n handle.addEventListener 'keydown', (e) => @_onKeydown(idx, e)\n\n _getPos: (e) ->\n if @orientation is 'horizontal' then e.clientX else e.clientY\n\n _getContainerSize: ->\n rect = @_root?.getBoundingClientRect()\n return 0 unless rect\n if @orientation is 'horizontal' then rect.width else rect.height\n\n _onPointerDown: (handleIdx, e) ->\n e.preventDefault()\n _dragging = handleIdx\n _startPos = @_getPos(e)\n _startSizes = [...sizes]\n e.target.setPointerCapture(e.pointerId)\n e.target.toggleAttribute 'data-dragging', true\n\n _onPointerMove: (e) ->\n return unless _dragging?\n total = @_getContainerSize()\n return unless total\n delta = @_getPos(e) - _startPos\n pctDelta = (delta / total) * 100\n @_applyResize(_dragging, _startSizes[_dragging] + pctDelta, _startSizes[_dragging + 1] - pctDelta)\n\n _onPointerUp: (e) ->\n return unless _dragging?\n handle = @_handles()[_dragging]\n handle?.removeAttribute 'data-dragging'\n _dragging = null\n @emit 'resize', sizes\n\n _applyResize: (idx, newLeft, newRight) ->\n newLeft = Math.max(@minSize, Math.min(@maxSize, newLeft))\n newRight = Math.max(@minSize, Math.min(@maxSize, newRight))\n combined = sizes[idx] + sizes[idx + 1]\n newRight = combined - newLeft\n return if newRight < @minSize or newRight > @maxSize\n updated = [...sizes]\n updated[idx] = newLeft\n updated[idx + 1] = newRight\n sizes = updated\n\n _onKeydown: (handleIdx, e) ->\n step = 10\n horiz = @orientation is 'horizontal'\n delta = switch e.key\n when (if horiz then 'ArrowRight' else 'ArrowDown') then step\n when (if horiz then 'ArrowLeft' else 'ArrowUp') then -step\n else null\n return unless delta?\n e.preventDefault()\n @_applyResize(handleIdx, sizes[handleIdx] + delta, sizes[handleIdx + 1] - delta)\n @emit 'resize', sizes\n\n ~>\n return unless _ready\n @_panels().forEach (el, idx) =>\n pct = sizes[idx] or 0\n el.style.setProperty '--panel-size', \"#{pct}%\"\n el.style.flexBasis = \"#{pct}%\"\n @_handles().forEach (handle, idx) =>\n handle.setAttribute 'aria-valuenow', Math.round(sizes[idx] or 0)\n handle.setAttribute 'aria-orientation', @orientation\n\n render\n div ref: \"_root\", $orientation: @orientation\n @pointermove: @_onPointerMove\n @pointerup: @_onPointerUp\n style: \"display:flex; flex-direction:#{if @orientation is 'horizontal' then 'row' else 'column'}\"\n slot\n",
12
+ "_pkg/ui/scroll-area.rip": "# ScrollArea — accessible headless custom scrollbar\n#\n# Renders custom scrollbar thumb that tracks scroll position. Thumb is\n# draggable and the track is clickable. Auto-hides when not scrolling.\n# Ships zero CSS.\n#\n# Usage:\n# ScrollArea\n# div \"Long scrollable content...\"\n\nMIN_THUMB = 20\n\nexport ScrollArea = component\n @orientation := \"vertical\"\n\n hovering := false\n scrolling := false\n _scrollTimer = null\n _dragStart = 0\n _dragScrollStart = 0\n _dragging = false\n _ready := false\n\n _updateThumb: ->\n vp = @_viewport\n sb = @_scrollbar\n th = @_thumb\n return unless vp and sb and th\n vert = @orientation is 'vertical'\n\n vpSize = if vert then vp.clientHeight else vp.clientWidth\n scSize = if vert then vp.scrollHeight else vp.scrollWidth\n sbSize = if vert then sb.clientHeight else sb.clientWidth\n scrollPos = if vert then vp.scrollTop else vp.scrollLeft\n\n ratio = vpSize / (scSize or 1)\n if ratio >= 1\n th.style.display = 'none'\n return\n th.style.display = ''\n\n thumbPx = Math.max(MIN_THUMB, sbSize * ratio)\n scrollRange = scSize - vpSize\n maxOffset = sbSize - thumbPx\n posPx = if scrollRange > 0 then Math.min(maxOffset, Math.max(0, (scrollPos / scrollRange) * maxOffset)) else 0\n\n if vert\n th.style.height = \"#{thumbPx}px\"\n th.style.transform = \"translate3d(0,#{posPx}px,0)\"\n else\n th.style.width = \"#{thumbPx}px\"\n th.style.transform = \"translate3d(#{posPx}px,0,0)\"\n\n _onScroll: ->\n scrolling = true\n clearTimeout _scrollTimer if _scrollTimer\n _scrollTimer = setTimeout (-> scrolling = false), 800\n @_updateThumb()\n\n _onTrackClick: (e) ->\n return if e.target is @_thumb\n vp = @_viewport\n sb = @_scrollbar\n th = @_thumb\n return unless vp and sb and th\n vert = @orientation is 'vertical'\n rect = sb.getBoundingClientRect()\n thumbPx = if vert then th.offsetHeight else th.offsetWidth\n\n if vert\n clickPos = e.clientY - rect.top - thumbPx / 2\n maxOffset = rect.height - thumbPx\n ratio = Math.max(0, Math.min(1, clickPos / maxOffset))\n vp.scrollTop = ratio * (vp.scrollHeight - vp.clientHeight)\n else\n clickPos = e.clientX - rect.left - thumbPx / 2\n maxOffset = rect.width - thumbPx\n ratio = Math.max(0, Math.min(1, clickPos / maxOffset))\n vp.scrollLeft = ratio * (vp.scrollWidth - vp.clientWidth)\n\n _onThumbDown: (e) ->\n return if e.button isnt 0\n e.preventDefault()\n e.stopPropagation()\n _dragging = true\n if @orientation is 'vertical'\n _dragStart = e.clientY\n _dragScrollStart = @_viewport.scrollTop\n else\n _dragStart = e.clientX\n _dragScrollStart = @_viewport.scrollLeft\n @_thumb.setPointerCapture e.pointerId\n\n _onThumbMove: (e) ->\n return unless _dragging\n vp = @_viewport\n sb = @_scrollbar\n th = @_thumb\n return unless vp and sb and th\n vert = @orientation is 'vertical'\n thumbPx = if vert then th.offsetHeight else th.offsetWidth\n sbSize = if vert then sb.clientHeight else sb.clientWidth\n maxOffset = sbSize - thumbPx\n return if maxOffset <= 0\n\n delta = if vert then e.clientY - _dragStart else e.clientX - _dragStart\n scrollRange = if vert then vp.scrollHeight - vp.clientHeight else vp.scrollWidth - vp.clientWidth\n newPos = _dragScrollStart + (delta / maxOffset) * scrollRange\n\n if vert then vp.scrollTop = newPos else vp.scrollLeft = newPos\n\n _onThumbUp: (e) ->\n _dragging = false\n @_thumb.releasePointerCapture e.pointerId\n\n mounted: ->\n _ready = true\n requestAnimationFrame => @_updateThumb()\n if @_viewport\n @_resizeObs = new ResizeObserver => @_updateThumb()\n @_resizeObs.observe @_viewport\n @_resizeObs.observe @_viewport.firstElementChild if @_viewport.firstElementChild\n\n beforeUnmount: ->\n @_resizeObs?.disconnect()\n\n render\n div $orientation: @orientation\n $hovering: hovering?!\n $scrolling: scrolling?!\n $dragging: _dragging?!\n @mouseenter: (=> hovering = true)\n @mouseleave: (=> hovering = false)\n\n div ref: \"_viewport\", $viewport: true\n style: \"overflow:scroll;scrollbar-width:none\"\n @scroll: @_onScroll\n slot\n\n div ref: \"_scrollbar\", $scrollbar: true\n @click: @_onTrackClick\n div ref: \"_thumb\", $thumb: true\n @pointerdown: @_onThumbDown\n @pointermove: @_onThumbMove\n @pointerup: @_onThumbUp\n",
13
+ "_pkg/ui/drawer.rip": "# Drawer — accessible headless slide-out panel\n#\n# A Dialog variant that slides from an edge of the screen.\n# Supports dismiss on escape, click-outside, and optional swipe-to-close.\n# Ships zero CSS.\n#\n# Usage:\n# Drawer open <=> showDrawer, side: \"right\"\n# h2 \"Settings\"\n# p \"Panel content here\"\n\nexport Drawer = component\n @open := false\n @side := \"right\"\n @dismissable := true\n\n _id =! \"drw-#{Math.random().toString(36).slice(2, 8)}\"\n _focusRaf = null\n\n beforeUnmount: ->\n cancelAnimationFrame _focusRaf if _focusRaf\n _focusRaf = null\n\n ~>\n # Same dedup as Dialog: bindDialog handles cancel/close emission;\n # onKeydown only intercepts Escape when NOT dismissable (so the\n # native dialog's default cancel behavior is suppressed).\n ARIA.bindDialog @open, (=> @_dialog), ((isOpen) =>\n if not isOpen and @open\n @open = false\n @emit 'close'\n ), @dismissable\n\n ~>\n if @open\n ARIA.lockScroll(this)\n _focusRaf = requestAnimationFrame =>\n _focusRaf = null\n panel = @_dialog\n if panel\n ARIA.wireAria panel, _id\n panel.querySelectorAll('a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex=\"-1\"])')?[0]?.focus()\n return ->\n cancelAnimationFrame _focusRaf if _focusRaf\n _focusRaf = null\n ARIA.unlockScroll(this)\n\n close: ->\n @open = false\n\n onKeydown: (e) ->\n # See Dialog: avoid double-emit by only intercepting non-dismissable.\n if e.key is 'Escape' and not @dismissable\n e.preventDefault()\n\n onBackdropClick: (e) ->\n if e.target is e.currentTarget and @dismissable\n @_dialog?.close()\n\n render\n # $side written once (was duplicated).\n dialog ref: \"_dialog\"\n hidden: not @open\n aria-hidden: (@open ? undefined : \"true\")\n $open: @open?!\n $side: @side\n @click: @onBackdropClick\n @keydown: @onKeydown\n slot\n",
14
+ "_pkg/ui/accordion.rip": "# Accordion — accessible headless expand/collapse widget\n#\n# Supports single or multiple expanded sections. Keyboard: Enter/Space to\n# toggle, ArrowDown/Up to move between triggers. Exposes $open on items.\n# Ships zero CSS.\n#\n# Usage:\n# Accordion multiple: false\n# div $item: \"a\"\n# button $trigger: true, \"Section A\"\n# div $content: true\n# p \"Content A\"\n# div $item: \"b\"\n# button $trigger: true, \"Section B\"\n# div $content: true\n# p \"Content B\"\n\nexport Accordion = component\n @multiple := false\n\n openItems := new Set()\n _ready := false\n _id =! \"acc-#{Math.random().toString(36).slice(2, 8)}\"\n\n mounted: ->\n _ready = true\n @_content?.querySelectorAll('[data-trigger]').forEach (trigger) =>\n item = trigger.closest('[data-item]')\n return unless item\n id = item.dataset.item\n trigger.addEventListener 'click', =>\n return if item.hasAttribute('data-disabled')\n @toggle(id)\n trigger.addEventListener 'keydown', (e) => @onTriggerKeydown(e, id)\n\n ~>\n return unless _ready\n @_content?.querySelectorAll('[data-item]').forEach (item) =>\n id = item.dataset.item\n isOpen = openItems.has(id)\n item.toggleAttribute 'data-open', isOpen\n trigger = item.querySelector('[data-trigger]')\n content = item.querySelector('[data-content]')\n triggerId = \"#{_id}-trigger-#{id}\"\n panelId = \"#{_id}-panel-#{id}\"\n if trigger\n isDisabled = item.hasAttribute('data-disabled')\n trigger.id = triggerId\n trigger.setAttribute 'aria-expanded', isOpen\n trigger.setAttribute 'aria-controls', panelId\n if isDisabled then trigger.setAttribute 'aria-disabled', true else trigger.removeAttribute 'aria-disabled'\n trigger.tabIndex = if isDisabled then -1 else 0\n if content\n content.id = panelId\n content.hidden = if isOpen then false else 'until-found'\n content.setAttribute 'role', 'region'\n content.setAttribute 'aria-labelledby', triggerId\n if isOpen\n rect = content.getBoundingClientRect()\n content.style.setProperty '--accordion-panel-height', \"#{rect.height}px\"\n content.style.setProperty '--accordion-panel-width', \"#{rect.width}px\"\n\n toggle: (id) ->\n if openItems.has(id)\n openItems.delete(id)\n else\n openItems.clear() unless @multiple\n openItems.add(id)\n openItems = new Set(openItems)\n @emit 'change', Array.from(openItems)\n\n isOpen: (id) ->\n openItems.has(id)\n\n onTriggerKeydown: (e, id) ->\n disabled = e.currentTarget.closest('[data-item]')?.hasAttribute('data-disabled')\n ARIA.rovingNav e, {\n next: => @_focusNext(1)\n prev: => @_focusNext(-1)\n first: => @_focusTrigger(0)\n last: => @_focusTrigger(-1)\n select: => @toggle(id) unless disabled\n }, 'vertical'\n\n _triggers: ->\n return [] unless @_content\n Array.from(@_content.querySelectorAll('[data-trigger]'))\n\n _focusNext: (dir) ->\n triggers = @_triggers()\n idx = triggers.indexOf(document.activeElement)\n return if idx is -1\n next = (idx + dir) %% triggers.length\n triggers[next]?.focus()\n\n _focusTrigger: (idx) ->\n triggers = @_triggers()\n target = if idx < 0 then triggers[triggers.length - 1] else triggers[idx]\n target?.focus()\n\n render\n div ref: \"_content\"\n slot\n",
15
+ "_pkg/ui/pagination.rip": "# Pagination — accessible headless page navigation\n#\n# Renders page buttons with prev/next and ellipsis gaps.\n# Ships zero CSS.\n#\n# Usage:\n# Pagination page <=> currentPage, total: 100, perPage: 10\n# Pagination page <=> currentPage, total: 500, perPage: 20, siblingCount: 2\n\nexport Pagination = component\n @page := 1\n @total := 0\n @perPage := 10\n @siblingCount := 1\n\n totalPages ~= Math.max(1, Math.ceil(@total / @perPage))\n _ready := false\n\n _range: (start, fin) ->\n len = fin - start + 1\n Array.from {length: len}, (_, i) -> start + i\n\n visiblePages ~=\n tp = totalPages\n sibs = @siblingCount\n current = @page\n\n totalNumbers = sibs * 2 + 5\n return @_range(1, tp) if tp <= totalNumbers\n\n leftSib = Math.max(current - sibs, 1)\n rightSib = Math.min(current + sibs, tp)\n\n showLeftDots = leftSib > 2\n showRightDots = rightSib < tp - 1\n\n if not showLeftDots and showRightDots\n leftCount = 3 + 2 * sibs\n leftRange = @_range(1, leftCount)\n return [...leftRange, -1, tp]\n\n if showLeftDots and not showRightDots\n rightCount = 3 + 2 * sibs\n rightRange = @_range(tp - rightCount + 1, tp)\n return [1, -2, ...rightRange]\n\n midRange = @_range(leftSib, rightSib)\n [1, -2, ...midRange, -1, tp]\n\n goto: (pg) ->\n pg = Math.max(1, Math.min(pg, totalPages))\n return if pg is @page\n @page = pg\n @emit 'change', @page\n\n onKeydown: (e) ->\n switch e.key\n when 'ArrowLeft'\n e.preventDefault()\n @goto(@page - 1)\n when 'ArrowRight'\n e.preventDefault()\n @goto(@page + 1)\n when 'Home'\n e.preventDefault()\n @goto(1)\n when 'End'\n e.preventDefault()\n @goto(totalPages)\n\n mounted: ->\n _ready = true\n\n _prevPages = null\n\n _rebuild: (inner) ->\n frag = document.createDocumentFragment()\n for pg in visiblePages\n if pg < 0\n el = document.createElement 'span'\n el.setAttribute 'data-ellipsis', ''\n el.textContent = '...'\n else\n el = document.createElement 'button'\n el.setAttribute 'aria-label', \"Page #{pg}\"\n el.setAttribute 'data-page', ''\n el.textContent = \"#{pg}\"\n el.addEventListener 'click', => @goto(pg)\n frag.appendChild el\n inner.replaceChildren frag\n _prevPages = visiblePages.join ','\n\n _syncActive: (inner) ->\n for btn in inner.querySelectorAll('[data-page]')\n pg = parseInt btn.textContent\n if pg is @page\n btn.setAttribute 'aria-current', 'page'\n btn.setAttribute 'data-active', ''\n else\n btn.removeAttribute 'aria-current'\n btn.removeAttribute 'data-active'\n\n ~>\n return unless _ready\n inner = @_nav?.querySelector('[data-pages]')\n return unless inner\n\n key = visiblePages.join ','\n if key isnt _prevPages\n @_rebuild inner\n @_syncActive inner\n\n render\n nav ref: \"_nav\", aria-label: \"Pagination\", @keydown: @onKeydown\n button $prev: true, aria-label: \"Previous page\"\n disabled: @page <= 1\n $disabled: (@page <= 1)?!\n @click: (=> @goto(@page - 1))\n . $pages: true\n button $next: true, aria-label: \"Next page\"\n disabled: @page >= totalPages\n $disabled: (@page >= totalPages)?!\n @click: (=> @goto(@page + 1))\n",
16
+ "_pkg/ui/alert-dialog.rip": "# AlertDialog — accessible headless non-dismissable modal\n#\n# A Dialog variant that requires explicit user action to close.\n# Cannot be dismissed by clicking outside or pressing Escape.\n# Use for destructive confirmations, unsaved changes, etc.\n# Ships zero CSS.\n#\n# Usage:\n# AlertDialog open <=> showConfirm\n# h2 \"Delete account?\"\n# p \"This action cannot be undone.\"\n# button @click: (=> showConfirm = false), \"Cancel\"\n# button @click: handleDelete, \"Delete\"\n\nexport AlertDialog = component\n @open := false\n @initialFocus := null\n\n _id =! \"adlg-#{Math.random().toString(36).slice(2, 8)}\"\n _focusRaf = null\n\n beforeUnmount: ->\n cancelAnimationFrame _focusRaf if _focusRaf\n _focusRaf = null\n\n ~>\n # bindDialog handles focus restoration via its own restoreEl\n # tracking — we don't need a parallel _prevFocus + manual focus()\n # in the cleanup, which used to double-restore (steal focus\n # incorrectly when nested modals or async UI was involved).\n ARIA.bindDialog @open, (=> @_dialog), ((isOpen) =>\n if not isOpen and @open\n @open = false\n @emit 'close'\n ), false\n\n ~>\n if @open\n ARIA.lockScroll(this)\n _focusRaf = requestAnimationFrame =>\n _focusRaf = null\n panel = @_dialog\n if panel\n ARIA.wireAria panel, _id\n if @initialFocus\n target = if typeof @initialFocus is 'string' then panel.querySelector(@initialFocus) else @initialFocus\n target?.focus()\n else\n panel.querySelectorAll('a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex=\"-1\"])')?[0]?.focus()\n return ->\n cancelAnimationFrame _focusRaf if _focusRaf\n _focusRaf = null\n ARIA.unlockScroll(this)\n\n close: ->\n @open = false\n\n render\n # role=\"alertdialog\" set statically (was previously set after RAF —\n # accessibility timing gap on first paint).\n dialog ref: \"_dialog\", role: \"alertdialog\"\n hidden: not @open\n aria-hidden: (@open ? undefined : \"true\")\n $open: @open?!\n slot\n",
17
+ "_pkg/ui/editable-value.rip": "# EditableValue — accessible headless inline editable value\n#\n# Displays a value with an edit trigger. Clicking opens a popover form.\n# Emits 'save' on submit. Ships zero CSS.\n#\n# Usage:\n# EditableValue @save: handleSave\n# span $display: true\n# \"John Doe\"\n# div $editor: true\n# input type: \"text\", value: name, @input: (e) => name = e.target.value\n\nexport EditableValue = component\n @disabled := false\n\n editing := false\n saving := false\n _ready := false\n\n _onEdit: ->\n return if @disabled\n editing = true\n requestAnimationFrame => @_position()\n\n _onSave: ->\n return if saving\n saving = true\n @emit 'save'\n @close()\n\n _onCancel: ->\n editing = false\n saving = false\n\n close: ->\n editing = false\n saving = false\n\n setSaving: (val) -> saving = val\n\n _position: ->\n display = @_root?.querySelector('[data-display]')\n editor = @_root?.querySelector('[data-editor]')\n return unless display and editor\n @_root.style.position = 'relative'\n dr = display.getBoundingClientRect()\n cr = @_root.getBoundingClientRect()\n editor.style.position = 'absolute'\n editor.style.left = \"0px\"\n editor.style.top = \"#{dr.bottom - cr.top + 4}px\"\n editor.style.zIndex = '50'\n editor.querySelector('input, textarea, select')?.focus()\n\n mounted: ->\n _ready = true\n\n ~>\n _readyNow = _ready\n _editing = editing # track before any early return\n return unless _readyNow\n display = @_root?.querySelector('[data-display]')\n editor = @_root?.querySelector('[data-editor]')\n return unless display and editor\n editor.hidden = not _editing\n if _editing\n editor.setAttribute 'data-open', ''\n onDown = (e) =>\n unless @_root?.contains(e.target)\n @_onCancel()\n document.addEventListener 'mousedown', onDown\n return -> document.removeEventListener 'mousedown', onDown\n else\n editor.removeAttribute 'data-open'\n\n onKeydown: (e) ->\n if e.key is 'Escape' and editing\n e.preventDefault()\n @_onCancel()\n if e.key is 'Enter' and editing\n e.preventDefault()\n @_onSave()\n\n render\n div ref: \"_root\", $editing: editing?!, $disabled: @disabled?!, $saving: saving?!\n slot\n unless editing\n button $edit-trigger: true, aria-label: \"Edit\", @click: @_onEdit\n \"✎\"\n",
18
+ "_pkg/ui/date-picker.rip": "# DatePicker — accessible headless date picker with calendar\n#\n# A popover calendar for selecting a single date or a date range.\n# Set @range to true for range selection (value becomes [from, to]).\n# Keyboard: Arrow keys navigate days, Enter selects, Escape closes.\n# Ships zero CSS — style entirely via attribute selectors in your stylesheet.\n#\n# Usage:\n# DatePicker value <=> selectedDate, placeholder: \"Pick a date\"\n# DatePicker value <=> dateRange, range: true\n\ndpFmt = (d) ->\n return '' unless d\n m = String(d.getMonth() + 1).padStart(2, '0')\n day = String(d.getDate()).padStart(2, '0')\n \"#{m}/#{day}/#{d.getFullYear()}\"\n\ndpParse = (str) ->\n return null unless str?.length is 10\n parts = str.split('/')\n return null unless parts.length is 3\n [m, d, y] = parts.map Number\n return null if isNaN(m) or isNaN(d) or isNaN(y)\n dt = new Date(y, m - 1, d)\n return null if dt.getMonth() isnt m - 1\n dt\n\ndpSameDay = (a, b) ->\n return false unless a and b\n a.getFullYear() is b.getFullYear() and a.getMonth() is b.getMonth() and a.getDate() is b.getDate()\n\ndpInRange = (day, from, to) ->\n return false unless day and from and to\n t = day.getTime()\n lo = Math.min(from.getTime(), to.getTime())\n hi = Math.max(from.getTime(), to.getTime())\n t >= lo and t <= hi\n\nexport DatePicker = component\n @value := null\n @placeholder := \"mm/dd/yyyy\"\n @disabled := false\n @range := false\n @firstDayOfWeek := 0\n\n open := false\n viewMonth := new Date()\n _rangeStart := null\n _hoveredDay := null\n _focusedDay := null # currently keyboard-focused day in the grid\n _id =! \"dp-#{Math.random().toString(36).slice(2, 8)}\"\n\n _BASE_DAY_NAMES =! ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']\n\n # Headers rotate with @firstDayOfWeek so the column labels match the\n # grid layout. Previously the grid shifted but the labels stayed\n # 'Su Mo Tu...' — confusing.\n _dayNames ~=\n f = (+@firstDayOfWeek) %% 7\n [..._BASE_DAY_NAMES.slice(f), ..._BASE_DAY_NAMES.slice(0, f)]\n\n # Coerce value to a Date if it's a serialized string (from JSON\n # persistence). The original code crashed in dpFmt(@value) when\n # @value was a string after rehydration.\n _coerceDate = (v) ->\n return null unless v\n return v if v instanceof Date\n d = new Date(v)\n return null if isNaN(d.getTime())\n d\n\n _value ~=\n if @range\n if Array.isArray(@value) then [_coerceDate(@value[0]), _coerceDate(@value[1])] else [null, null]\n else\n _coerceDate(@value)\n\n _daysInView ~=\n yr = viewMonth.getFullYear()\n mo = viewMonth.getMonth()\n firstOfMonth = new Date(yr, mo, 1)\n startDay = firstOfMonth.getDay()\n offset = (startDay - @firstDayOfWeek + 7) %% 7\n daysInMonth = new Date(yr, mo + 1, 0).getDate()\n prevMonthDays = new Date(yr, mo, 0).getDate()\n dayList = []\n for n in [0...offset]\n dayList.push { date: new Date(yr, mo - 1, prevMonthDays - offset + n + 1), outside: true }\n for n in [1..daysInMonth]\n dayList.push { date: new Date(yr, mo, n), outside: false }\n trailing = (7 - dayList.length %% 7) %% 7\n for n in [1..trailing]\n dayList.push { date: new Date(yr, mo + 1, n), outside: true }\n dayList\n\n # Group the flat dayList into 7-day weeks for proper role=row\n # rendering. Always a clean multiple of 7 thanks to the trailing\n # padding above.\n _weeksInView ~=\n weeks = []\n days = _daysInView\n i = 0\n while i < days.length\n weeks.push days.slice(i, i + 7)\n i += 7\n weeks\n\n _displayText ~=\n if @range\n pair = _value\n if pair[0]\n from = dpFmt(pair[0])\n to = if pair[1] then dpFmt(pair[1]) else '...'\n \"#{from} – #{to}\"\n else\n @placeholder\n else\n if _value then dpFmt(_value) else @placeholder\n\n _monthLabel ~=\n viewMonth.toLocaleDateString(undefined, { month: 'long', year: 'numeric' })\n\n _today =! new Date()\n\n _prevMonth: ->\n viewMonth = new Date(viewMonth.getFullYear(), viewMonth.getMonth() - 1, 1)\n\n _nextMonth: ->\n viewMonth = new Date(viewMonth.getFullYear(), viewMonth.getMonth() + 1, 1)\n\n _selectDay: (day) ->\n return if @disabled\n if @range\n if _rangeStart\n # Same-day click completes a single-day range (was a no-op).\n from = if _rangeStart < day then _rangeStart else day\n to = if _rangeStart < day then day else _rangeStart\n @value = [from, to]\n _rangeStart = null\n @emit 'change', @value\n open = false\n else\n _rangeStart = day\n @value = [day, null]\n else\n @value = day\n @emit 'change', @value\n open = false\n\n toggle: ->\n return if @disabled\n if open then @close() else @openPicker()\n\n openPicker: ->\n open = true\n cur = _value\n if cur and not @range\n viewMonth = new Date(cur.getFullYear(), cur.getMonth(), 1)\n _focusedDay = cur\n else if @range and Array.isArray(cur) and cur[0]\n viewMonth = new Date(cur[0].getFullYear(), cur[0].getMonth(), 1)\n _focusedDay = cur[0]\n else\n _focusedDay = new Date(viewMonth.getFullYear(), viewMonth.getMonth(), 1)\n requestAnimationFrame =>\n @_position()\n @_focusGridDay()\n\n close: ->\n open = false\n _rangeStart = null\n _hoveredDay = null\n _focusedDay = null\n\n _position: -> ARIA.positionBelow @_trigger, @_cal, 4, false\n\n # Focus the day-cell whose date matches _focusedDay. Uses a\n # data-date attribute we put on each gridcell button.\n _focusGridDay: ->\n return unless @_cal and _focusedDay\n iso = \"#{_focusedDay.getFullYear()}-#{_focusedDay.getMonth()}-#{_focusedDay.getDate()}\"\n @_cal.querySelector(\"[data-date='#{iso}']\")?.focus()\n\n _shiftFocus: (delta) ->\n return unless _focusedDay\n next = new Date(_focusedDay.getFullYear(), _focusedDay.getMonth(), _focusedDay.getDate() + delta)\n _focusedDay = next\n # If the new day lands outside the current view month, advance the view.\n if next.getMonth() isnt viewMonth.getMonth() or next.getFullYear() isnt viewMonth.getFullYear()\n viewMonth = new Date(next.getFullYear(), next.getMonth(), 1)\n requestAnimationFrame => @_focusGridDay()\n\n _onTriggerKeydown: (e) ->\n switch e.key\n when 'Escape'\n if open\n e.preventDefault()\n @close()\n when 'Enter', ' ', 'ArrowDown'\n unless open\n e.preventDefault()\n @openPicker()\n\n # Full grid keyboard support (was missing entirely — the gridcells\n # had tabIndex:-1 and no key handler so users couldn't navigate days\n # with arrow keys at all). APG date-picker pattern:\n # ArrowLeft/Right ±1 day\n # ArrowUp/Down ±1 week\n # PageUp/Down ±1 month\n # Shift+PageUp/Dn ±1 year\n # Home/End start/end of week\n # Enter / Space select focused day\n # Escape close + return focus to trigger\n _onGridKeydown: (e) ->\n return if @disabled\n switch e.key\n when 'ArrowLeft' then e.preventDefault(); @_shiftFocus(-1)\n when 'ArrowRight' then e.preventDefault(); @_shiftFocus(1)\n when 'ArrowUp' then e.preventDefault(); @_shiftFocus(-7)\n when 'ArrowDown' then e.preventDefault(); @_shiftFocus(7)\n when 'PageUp'\n e.preventDefault()\n days = if e.shiftKey then 365 else 30\n @_shiftFocus(-days)\n when 'PageDown'\n e.preventDefault()\n days = if e.shiftKey then 365 else 30\n @_shiftFocus(days)\n when 'Home'\n e.preventDefault()\n # Snap to start of current week given firstDayOfWeek.\n delta = -((_focusedDay.getDay() - @firstDayOfWeek + 7) %% 7)\n @_shiftFocus(delta)\n when 'End'\n e.preventDefault()\n delta = 6 - ((_focusedDay.getDay() - @firstDayOfWeek + 7) %% 7)\n @_shiftFocus(delta)\n when 'Enter', ' '\n e.preventDefault()\n @_selectDay(_focusedDay) if _focusedDay\n when 'Escape'\n e.preventDefault()\n @close()\n @_trigger?.focus()\n\n ~>\n if open\n onDown = (e) =>\n unless @_trigger?.contains(e.target) or @_cal?.contains(e.target)\n @close()\n document.addEventListener 'mousedown', onDown\n return -> document.removeEventListener 'mousedown', onDown\n\n render\n . $open: open?!, $disabled: @disabled?!, $range: @range?!\n\n # Trigger\n button type: \"button\", ref: \"_trigger\", $trigger: true\n aria-haspopup: \"dialog\"\n aria-expanded: !!open\n disabled: @disabled\n @click: @toggle\n @keydown: @_onTriggerKeydown\n _displayText\n\n # Calendar dropdown\n if open\n div ref: \"_cal\", role: \"dialog\", aria-label: \"Date picker\", $calendar: true\n style: \"position:fixed;z-index:50\"\n @keydown: @_onGridKeydown\n\n # Month navigation\n . $header: true\n button type: \"button\", $prev: true, aria-label: \"Previous month\", @click: @_prevMonth\n \"‹\"\n span $month-label: true\n _monthLabel\n button type: \"button\", $next: true, aria-label: \"Next month\", @click: @_nextMonth\n \"›\"\n\n # Day-of-week headers\n . $weekdays: true\n for dayName in _dayNames\n span $weekday: true\n dayName\n\n # Day grid with proper APG row structure. _daysInView is a\n # flat list of 7-day weeks (always a multiple of 7); we\n # group them into row containers so screen readers announce\n # \"row N of M\" while navigating with arrow keys. Cells use\n # roving-tabindex pattern: only the _focusedDay cell has\n # tabIndex 0, arrow keys move within. data-date is used by\n # _focusGridDay to find the cell to focus after view changes.\n . $days: true, role: \"grid\"\n for week, weekIdx in _weeksInView\n . $week: true, role: \"row\"\n for entry, dIdx in week\n button type: \"button\", role: \"gridcell\"\n data-date: \"#{entry.date.getFullYear()}-#{entry.date.getMonth()}-#{entry.date.getDate()}\"\n tabIndex: (if dpSameDay(entry.date, _focusedDay) then 0 else -1)\n $outside: entry.outside?!\n $today: dpSameDay(entry.date, _today)?!\n $selected: (if @range then (Array.isArray(_value) and (dpSameDay(entry.date, _value[0]) or dpSameDay(entry.date, _value[1]))) else dpSameDay(entry.date, _value))?!\n $in-range: (if @range then (if _rangeStart then dpInRange(entry.date, _rangeStart, _hoveredDay or _rangeStart) else if Array.isArray(_value) and _value[0] and _value[1] then dpInRange(entry.date, _value[0], _value[1]) else false) else false)?!\n $range-start: (if @range and _rangeStart then dpSameDay(entry.date, _rangeStart) else false)?!\n @click: (=> @_selectDay(entry.date))\n @mouseenter: (=> _hoveredDay = entry.date)\n entry.date.getDate()\n",
19
+ "_pkg/ui/progress.rip": "# Progress — accessible headless progress bar\n#\n# Exposes progress value as CSS custom property for styling.\n# Ships zero CSS.\n#\n# Usage:\n# Progress value: 0.65\n# Progress value: 42, max: 100\n\nexport Progress = component\n @value := 0\n @max := 1\n @label := null\n\n percent ~= Math.min(100, Math.max(0, (@value / @max) * 100))\n\n render\n div role: \"progressbar\"\n aria-valuenow: @value\n aria-valuemin: 0\n aria-valuemax: @max\n aria-label: @label?!\n style: \"--progress-value: #{@value}; --progress-percent: #{percent}%\"\n $complete: (percent >= 100)?!\n slot\n",
20
+ "_pkg/ui/button-group.rip": "# ButtonGroup — accessible headless button group\n#\n# Groups related buttons with proper ARIA semantics.\n# Ships zero CSS.\n#\n# Usage:\n# ButtonGroup\n# Button \"Cut\"\n# Button \"Copy\"\n# Button \"Paste\"\n# ButtonGroup orientation: \"vertical\", label: \"Text formatting\"\n# Toggle pressed <=> isBold, \"Bold\"\n# Toggle pressed <=> isItalic, \"Italic\"\n\nexport ButtonGroup = component\n @orientation := \"horizontal\"\n @disabled := false\n @label := \"\"\n\n render\n div role: \"group\"\n aria-label: @label?!\n aria-orientation: @orientation\n $orientation: @orientation\n $disabled: @disabled?!\n slot\n",
21
+ "_pkg/ui/tabs.rip": "# Tabs — accessible headless tab widget\n#\n# Keyboard: ArrowLeft/Right (horizontal) or ArrowUp/Down (vertical) to navigate,\n# Home/End for first/last. Manages focus via roving tabindex.\n# Exposes $active on tabs and panels. Ships zero CSS.\n#\n# Props:\n# active — currently active tab id (two-way bindable)\n# orientation — 'horizontal' (default) or 'vertical'\n# activation — 'automatic' (default, selects on focus) or 'manual' (Enter/Space to select)\n#\n# Usage:\n# Tabs active <=> currentTab\n# div $tab: \"one\", \"Tab One\"\n# div $tab: \"two\", \"Tab Two\"\n# div $panel: \"one\"\n# p \"Content for tab one\"\n# div $panel: \"two\"\n# p \"Content for tab two\"\n\nexport Tabs = component\n @active := null\n @orientation := \"horizontal\"\n @activation := \"automatic\"\n _ready := false\n _id =! \"tabs-#{Math.random().toString(36).slice(2, 8)}\"\n activationDirection := 'none'\n\n tabs ~=\n return [] unless _ready\n Array.from(@_content?.querySelectorAll('[data-tab]') or [])\n\n panels ~=\n return [] unless _ready\n Array.from(@_content?.querySelectorAll('[data-panel]') or [])\n\n mounted: ->\n _ready = true\n unless @active\n @active = tabs[0]?.dataset.tab\n\n ~>\n return unless _ready\n tabs.forEach (el) -> el.hidden = true\n panels.forEach (el) =>\n id = el.dataset.panel\n isActive = id is @active\n el.id = \"#{_id}-panel-#{id}\"\n el.setAttribute 'role', 'tabpanel'\n el.setAttribute 'aria-labelledby', \"#{_id}-tab-#{id}\"\n el.toggleAttribute 'hidden', not isActive\n el.toggleAttribute 'data-active', isActive\n\n _isDisabled: (el) -> el?.hasAttribute('data-disabled')\n\n _tabButtons: ->\n Array.from(@_tablist?.querySelectorAll('[role=\"tab\"]') or [])\n\n select: (id) ->\n prev = @active\n horiz = @orientation is 'horizontal'\n if prev and id isnt prev\n oldTab = tabs.find (t) -> t.dataset.tab is prev\n newTab = tabs.find (t) -> t.dataset.tab is id\n if oldTab and newTab\n oldRect = oldTab.getBoundingClientRect()\n newRect = newTab.getBoundingClientRect()\n activationDirection = if horiz\n if newRect.left > oldRect.left then 'right' else 'left'\n else\n if newRect.top > oldRect.top then 'down' else 'up'\n @active = id\n @emit 'change', id\n\n _nextEnabled: (ids, from, dir) ->\n len = ids.length\n i = from\n loop len\n i = (i + dir) %% len\n tab = @_tabButtons().find (t) -> t.dataset.tab is ids[i]\n return ids[i] unless @_isDisabled(tab)\n ids[from]\n\n onKeydown: (e) ->\n buttons = @_tabButtons()\n ids = buttons.map (t) -> t.dataset.tab\n idx = ids.indexOf @active\n return if idx is -1\n move = (nextId) =>\n return unless nextId\n buttons.find((t) -> t.dataset.tab is nextId)?.focus()\n @select(nextId) if @activation is 'automatic'\n ARIA.rovingNav e, {\n next: => move(@_nextEnabled(ids, idx, 1))\n prev: => move(@_nextEnabled(ids, idx, -1))\n first: => move(@_nextEnabled(ids, ids.length - 1, 1))\n last: => move(@_nextEnabled(ids, 0, -1))\n select: => @select(ids[idx]) if @activation is 'manual'\n }, @orientation\n\n render\n .\n div ref: \"_tablist\", role: \"tablist\", aria-orientation: @orientation, data-activation-direction: activationDirection, @keydown: @onKeydown\n for tab in tabs\n button role: \"tab\"\n data-tab: tab.dataset.tab\n id: \"#{_id}-tab-#{tab.dataset.tab}\"\n aria-selected: tab.dataset.tab is @active\n aria-controls: \"#{_id}-panel-#{tab.dataset.tab}\"\n aria-disabled: @_isDisabled(tab)?!\n tabindex: if @_isDisabled(tab) then '-1' else (tab.dataset.tab is @active ? '0' : '-1')\n $active: (tab.dataset.tab is @active)?!\n $disabled: @_isDisabled(tab)?!\n @click: (=> @select(tab.dataset.tab) unless @_isDisabled(tab))\n = tab.textContent\n\n . ref: \"_content\"\n slot\n",
22
+ "_pkg/ui/input-group.rip": "# InputGroup — accessible headless input with prefix/suffix\n#\n# Wraps a form control with optional prefix and suffix elements.\n# Use $prefix and $suffix on children to mark addon positions.\n# Tracks child input focus for styling. Ships zero CSS.\n#\n# Usage:\n# InputGroup\n# span $prefix: true, \"$\"\n# Input value <=> amount, type: \"number\"\n# InputGroup\n# Input value <=> search, placeholder: \"Search...\"\n# button $suffix: true, @click: doSearch, \"Go\"\n\nexport InputGroup = component\n @disabled := false\n\n focused := false\n\n mounted: ->\n ctrl = @_root?.querySelector('input, select, textarea')\n return unless ctrl\n ctrl.addEventListener 'focusin', => focused = true\n ctrl.addEventListener 'focusout', => focused = false\n\n render\n div ref: \"_root\", $disabled: @disabled?!, $focused: focused?!\n slot\n",
23
+ "_pkg/ui/slider.rip": "# Slider — accessible headless range slider\n#\n# Supports single and range (multi-thumb) modes, pointer drag with capture,\n# keyboard stepping, and CSS custom properties for thumb/indicator positioning.\n# Ships zero CSS.\n#\n# Usage:\n# Slider value <=> volume\n# Slider value <=> volume, min: 0, max: 100, step: 5\n# Slider value <=> range, min: 0, max: 100 (pass array for range mode)\n\nexport Slider = component\n @value := 0 # number | number[] (range mode)\n @min := 0\n @max := 100\n @step := 1\n @largeStep := 10\n @orientation := \"horizontal\"\n @disabled := false\n @name := null\n @valueText := null\n\n dragging := false\n activeThumb := -1\n _thumbOffset = 0\n _id =! \"sld-#{Math.random().toString(36).slice(2, 8)}\"\n\n isRange ~= Array.isArray(@value)\n values ~= if isRange then @value else [@value]\n horiz ~= @orientation is 'horizontal'\n\n _clamp: (v) -> Math.min(@max, Math.max(@min, v))\n\n _roundToStep: (v) ->\n # Guard against step <= 0 (NaN, negative, or zero would explode the\n # rounding into Infinity/NaN). Fallback: pass the value through\n # unchanged.\n return @_clamp(v) unless @step > 0\n rounded = Math.round((v - @min) / @step) * @step + @min\n precision = String(@step).split('.')[1]?.length or 0\n parseFloat rounded.toFixed(precision)\n\n _percentOf: (v) ->\n # Guard against max == min (would produce NaN/Infinity in CSS and\n # ARIA values). Treat zero range as \"100% filled at min\".\n return 0 unless @max > @min\n ((@_clamp(v) - @min) / (@max - @min)) * 100\n\n _valueFromPointer: (e) ->\n rect = @_track.getBoundingClientRect()\n if horiz\n ratio = (e.clientX - _thumbOffset - rect.left) / rect.width\n else\n ratio = 1 - (e.clientY - _thumbOffset - rect.top) / rect.height\n ratio = Math.max(0, Math.min(1, ratio))\n @_roundToStep(@min + ratio * (@max - @min))\n\n _closestThumb: (e) ->\n return 0 unless isRange\n rect = @_track.getBoundingClientRect()\n pos = if horiz then (e.clientX - rect.left) / rect.width else 1 - (e.clientY - rect.top) / rect.height\n best = 0\n bestDist = Infinity\n for v, i in values\n pct = @_percentOf(v) / 100\n dist = Math.abs(pos - pct)\n if dist < bestDist\n bestDist = dist\n best = i\n best\n\n _setValue: (idx, val) ->\n val = @_clamp(@_roundToStep(val))\n if isRange\n arr = [...values]\n arr[idx] = val\n # Track which value was the \"active\" one BEFORE sorting so we can\n # restore activeThumb to its new index after sort. Without this,\n # crossing thumbs (lower drags past upper) leaves activeThumb\n # pointing at the wrong index — drag/focus jumps to the other\n # thumb mid-gesture.\n activeVal = arr[idx]\n arr.sort (a, b) -> a - b\n activeThumb = arr.indexOf(activeVal) if dragging or activeThumb is idx\n @value = arr\n else\n @value = val\n @emit 'input', @value\n\n _commitValue: ->\n @emit 'change', @value\n\n _focusThumb: (idx) ->\n requestAnimationFrame =>\n @_track?.querySelector(\"#\" + \"#{_id}-thumb-#{idx}\")?.focus()\n\n _onPointerDown: (e) ->\n return if @disabled or e.button isnt 0\n e.preventDefault()\n idx = @_closestThumb(e)\n activeThumb = idx\n dragging = true\n\n thumb = @_track.querySelectorAll('[data-thumb]')[idx]\n if thumb\n tr = thumb.getBoundingClientRect()\n if horiz\n _thumbOffset = e.clientX - (tr.left + tr.width / 2)\n else\n _thumbOffset = e.clientY - (tr.top + tr.height / 2)\n else\n _thumbOffset = 0\n\n newVal = @_valueFromPointer(e)\n @_setValue idx, newVal\n\n @_track.setPointerCapture e.pointerId\n\n _onPointerMove: (e) ->\n return unless dragging\n newVal = @_valueFromPointer(e)\n @_setValue activeThumb, newVal\n\n _onPointerUp: (e) ->\n return unless dragging\n dragging = false\n activeThumb = -1\n _thumbOffset = 0\n # releasePointerCapture can throw if capture was already lost (e.g.,\n # via pointercancel). Swallow defensively so a benign teardown\n # doesn't surface as an unhandled error.\n try @_track.releasePointerCapture e.pointerId\n catch then null\n @_commitValue()\n\n # Pointer capture can be lost involuntarily — touch interrupted by\n # the OS, page lost focus, etc. Without this, dragging stays true\n # and the next pointermove keeps moving the thumb without a real\n # pointerdown.\n _onPointerCancel: (e) ->\n return unless dragging\n dragging = false\n activeThumb = -1\n _thumbOffset = 0\n\n _onKeydown: (e, idx) ->\n return if @disabled\n s = if e.shiftKey then @largeStep else @step\n v = values[idx]\n newVal = switch e.key\n when 'ArrowRight', 'ArrowUp' then v + s\n when 'ArrowLeft', 'ArrowDown' then v - s\n when 'PageUp' then v + @largeStep\n when 'PageDown' then v - @largeStep\n when 'Home' then @min\n when 'End' then @max\n else null\n if newVal?\n e.preventDefault()\n @_setValue idx, newVal\n @_commitValue()\n @_focusThumb idx\n\n render\n div role: \"group\", $orientation: @orientation, $disabled: @disabled?!, $dragging: dragging?!\n style: \"--slider-min: #{@min}; --slider-max: #{@max}\"\n\n # Track\n div ref: \"_track\", $track: true\n style: \"position:relative\"\n @pointerdown: @_onPointerDown\n @pointermove: @_onPointerMove\n @pointerup: @_onPointerUp\n @pointercancel: @_onPointerCancel\n @lostpointercapture: @_onPointerCancel\n\n # Indicator (filled portion)\n if isRange\n div $indicator: true\n style: \"position:absolute; #{if horiz then 'left' else 'bottom'}: #{@_percentOf(values[0])}%; #{if horiz then 'width' else 'height'}: #{@_percentOf(values[1]) - @_percentOf(values[0])}%\"\n else\n div $indicator: true\n style: \"position:absolute; #{if horiz then 'left: 0; width' else 'bottom: 0; height'}: #{@_percentOf(values[0])}%\"\n\n # Thumbs\n for val, idx in values\n div $thumb: true, $active: (idx is activeThumb)?!\n style: \"position:absolute; #{if horiz then 'left' else 'bottom'}: #{@_percentOf(val)}%; z-index: #{if idx is activeThumb then 2 else 1}\"\n input type: \"range\", style: \"position:absolute;opacity:0;width:0;height:0;pointer-events:none\"\n id: \"#{_id}-thumb-#{idx}\"\n name: @name?!\n min: @min, max: @max, step: @step\n value: val\n disabled: @disabled\n aria-valuenow: val\n aria-valuemin: @min\n aria-valuemax: @max\n aria-valuetext: if @valueText then @valueText(val, idx) else undefined\n aria-orientation: @orientation\n aria-disabled: @disabled?!\n @keydown: (e) => @_onKeydown(e, idx)\n\n slot\n",
24
+ "_pkg/ui/card.rip": "# Card — accessible headless content container\n#\n# Structured container with optional header, content, and footer sections.\n# Use $header, $content, $footer on children to mark sections.\n# Ships zero CSS.\n#\n# Usage:\n# Card\n# div $header: true\n# h3 \"Title\"\n# div $content: true\n# p \"Body text\"\n# div $footer: true\n# Button \"Action\"\n#\n# Card interactive: true, @click: handleClick\n# p \"Clickable card\"\n\nexport Card = component\n @interactive := false\n\n render\n div tabindex: (if @interactive then \"0\" else undefined)\n $interactive: @interactive?!\n slot\n",
25
+ "_pkg/ui/popover.rip": "# Popover — accessible headless popover with anchor positioning\n#\n# Uses the native Popover API (top-layer + light-dismiss) and CSS anchor\n# positioning. Exposes $open, $placement on content. Ships zero CSS.\n#\n# Usage:\n# Popover placement: \"bottom-start\"\n# button $trigger: true, \"Click me\"\n# div $content: true\n# p \"Popover content\"\n\nexport Popover = component\n @placement := \"bottom-start\"\n @offset := 4\n @disabled := false\n @openOnHover := false\n @hoverDelay := 300\n @hoverCloseDelay := 200\n\n open := false\n _ready := false\n _hoverTimer := null\n _hoverCloseTimer := null\n _id =! \"pop-#{Math.random().toString(36).slice(2, 8)}\"\n\n _applyPlacement: ->\n trigger = @_content?.querySelector('[data-trigger]')\n floating = @_content?.querySelector('[data-content]')\n [side, align] = @placement.split('-')\n align ??= 'center'\n ARIA.position trigger, floating, placement: \"#{side} #{align}\", offset: @offset\n\n beforeUnmount: ->\n # Clear pending hover timers so an unmount during the open/close\n # delay doesn't fire on a destroyed instance.\n clearTimeout _hoverTimer if _hoverTimer\n clearTimeout _hoverCloseTimer if _hoverCloseTimer\n _hoverTimer = null\n _hoverCloseTimer = null\n\n mounted: ->\n _ready = true\n trigger = @_content?.querySelector('[data-trigger]')\n floating = @_content?.querySelector('[data-content]')\n if trigger and floating\n floating.id = _id\n floating.setAttribute 'popover', 'auto'\n trigger.setAttribute 'aria-expanded', false\n trigger.setAttribute 'aria-haspopup', 'dialog'\n trigger.setAttribute 'aria-controls', _id\n # Hover-open ALSO checks @disabled (parity with click/keydown\n # paths); the previous code opened a disabled popover after the\n # hover delay because only click/keydown were guarded.\n trigger.addEventListener 'click', =>\n return if @disabled\n open = not open\n trigger.addEventListener 'keydown', (e) =>\n if e.key in ['Enter', ' ', 'ArrowDown']\n e.preventDefault()\n return if @disabled\n open = not open\n if @openOnHover\n trigger.addEventListener 'mouseenter', =>\n return if @disabled\n clearTimeout _hoverCloseTimer if _hoverCloseTimer\n _hoverCloseTimer = null\n _hoverTimer = setTimeout (=> open = true), @hoverDelay\n trigger.addEventListener 'mouseleave', =>\n clearTimeout _hoverTimer if _hoverTimer\n _hoverTimer = null\n _hoverCloseTimer = setTimeout (=> open = false), @hoverCloseDelay\n @_applyPlacement()\n\n openPopover: ->\n return if @disabled\n open = true\n\n toggle: ->\n return if @disabled\n open = not open\n\n close: ->\n open = false\n\n ~>\n return unless _ready\n trigger = @_content?.querySelector('[data-trigger]')\n floating = @_content?.querySelector('[data-content]')\n if trigger\n trigger.setAttribute 'aria-expanded', !!open\n if floating\n floating.hidden = not open\n if open then floating.removeAttribute 'aria-hidden' else floating.setAttribute 'aria-hidden', 'true'\n floating.setAttribute 'data-placement', @placement\n if open then floating.setAttribute 'data-open', '' else floating.removeAttribute 'data-open'\n ARIA.wireAria floating, _id\n @_applyPlacement()\n\n ~>\n return unless _ready\n ARIA.bindPopover open, (=> @_content?.querySelector('[data-content]')), ((isOpen) => open = isOpen), (=> @_content?.querySelector('[data-trigger]'))\n\n onKeydown: (e) ->\n if e.key is 'Escape' and open\n e.preventDefault()\n @close()\n\n render\n div ref: \"_content\"\n slot\n",
26
+ "_pkg/ui/toggle.rip": "# Toggle — accessible headless toggle button\n#\n# Stateful button that toggles pressed state on click.\n# Ships zero CSS.\n#\n# Usage:\n# Toggle pressed <=> isBold\n# \"Bold\"\n\nexport Toggle = component\n @pressed := false\n @disabled := false\n\n onClick: ->\n return if @disabled\n @pressed = not @pressed\n @emit 'change', @pressed\n\n render\n button aria-pressed: !!@pressed\n aria-disabled: @disabled?!\n $pressed: @pressed?!\n $disabled: @disabled?!\n slot\n",
27
+ "_pkg/ui/button.rip": "# Button — accessible headless button\n#\n# Handles disabled-but-focusable pattern and pressed state.\n# Ships zero CSS.\n#\n# Usage:\n# Button @click: handleClick\n# \"Save\"\n# Button disabled: true\n# \"Unavailable\"\n\nexport Button = component extends button\n @disabled := false\n\n onClick: ->\n return if @disabled\n @emit 'press'\n\n render\n button disabled: @disabled\n aria-disabled: @disabled?!\n $disabled: @disabled?!\n slot\n",
28
+ "_pkg/ui/toast.rip": "# Toast — accessible headless toast notification system\n#\n# Managed toast system with stacking, timer pause on hover, and promise support.\n# Uses ARIA live region for screen reader announcements. Ships zero CSS.\n#\n# Usage:\n# toasts := []\n#\n# # Add a toast — reactive assignment is the API\n# toasts = [...toasts, { message: \"Saved!\", type: \"success\" }]\n#\n# # Dismiss — filter it out\n# toasts = toasts.filter (t) -> t isnt target\n#\n# # Clear all\n# toasts = []\n#\n# # In render block\n# ToastViewport toasts <=> toasts\n\nexport ToastViewport = component\n @toasts := []\n @placement := \"bottom-right\"\n\n _onDismiss: (toast) ->\n @toasts = @toasts.filter (t) -> t isnt toast\n\n render\n div role: \"region\", aria-label: \"Notifications\", $placement: @placement\n for toast in @toasts\n Toast toast: toast, @dismiss: (e) => @_onDismiss(e.detail)\n\nexport Toast = component\n @toast := {}\n\n leaving := false\n _timer := null\n _remaining = 0\n _started = 0\n\n _startTimer: ->\n dur = @toast.duration ?? 4000\n return unless dur > 0\n _remaining = dur\n _started = Date.now()\n _timer = setTimeout => @dismiss(), dur\n\n _pauseTimer: ->\n return unless _timer\n clearTimeout _timer\n _remaining -= Date.now() - _started\n _timer = null\n\n _resumeTimer: ->\n return if _timer or _remaining <= 0\n _started = Date.now()\n _timer = setTimeout => @dismiss(), _remaining\n\n mounted: -> @_startTimer()\n\n beforeUnmount: ->\n clearTimeout _timer if _timer\n\n dismiss: ->\n leaving = true\n setTimeout =>\n @emit 'dismiss', @toast\n , 200\n\n render\n div role: (if @toast.type is 'error' then 'alert' else 'status'),\n aria-live: (if @toast.type is 'error' then 'assertive' else 'polite'),\n $type: @toast.type ?? 'info',\n $leaving: leaving?!,\n @mouseenter: @_pauseTimer,\n @mouseleave: @_resumeTimer,\n @focusin: @_pauseTimer,\n @focusout: @_resumeTimer\n .\n if @toast.title\n strong @toast.title\n span @toast.message\n if @toast.action\n button @click: @toast.action.onClick\n @toast.action.label or 'Action'\n button aria-label: \"Dismiss\", @click: @dismiss\n \"✕\"\n",
29
+ "_pkg/ui/number-field.rip": "# NumberField — accessible headless number input with stepper buttons\n#\n# Increment/decrement with click, hold-to-repeat, and keyboard.\n# Supports min/max/step clamping and Shift/Alt step modifiers.\n# Ships zero CSS.\n#\n# Usage:\n# NumberField value <=> quantity\n# NumberField value <=> price, min: 0, max: 1000, step: 0.01\n\nSTART_DELAY = 400\nTICK_DELAY = 60\n\nexport NumberField = component\n @value := 0\n @min := null\n @max := null\n @step := 1\n @smallStep := 0.1\n @largeStep := 10\n @disabled := false\n @readOnly := false\n @name := null\n\n _timer = null\n _interval = null\n _id =! \"nf-#{Math.random().toString(36).slice(2, 8)}\"\n\n _clamp: (v) ->\n v = Math.max(@min, v) if @min?\n v = Math.min(@max, v) if @max?\n v\n\n _roundToStep: (v) ->\n base = @min ?? 0\n rounded = Math.round((v - base) / @step) * @step + base\n precision = String(@step).split('.')[1]?.length or 0\n parseFloat rounded.toFixed(precision)\n\n _stepAmount: (e) ->\n if e?.altKey then @smallStep\n else if e?.shiftKey then @largeStep\n else @step\n\n increment: (amount) ->\n return if @disabled or @readOnly\n @value = @_clamp(@_roundToStep(+@value + amount))\n @emit 'input', @value\n\n decrement: (amount) ->\n return if @disabled or @readOnly\n @value = @_clamp(@_roundToStep(+@value - amount))\n @emit 'input', @value\n\n _startRepeat: (dir, e) ->\n amount = @_stepAmount(e)\n tick = => if dir > 0 then @increment(amount) else @decrement(amount)\n tick()\n _timer = setTimeout =>\n _interval = setInterval tick, TICK_DELAY\n , START_DELAY\n\n _stopRepeat: ->\n clearTimeout _timer if _timer\n clearInterval _interval if _interval\n _timer = null\n _interval = null\n @emit 'change', @value\n\n _onIncDown: (e) ->\n return if @disabled or @readOnly or e.button isnt 0\n e.preventDefault()\n @_input?.focus()\n @_startRepeat 1, e\n onUp = =>\n @_stopRepeat()\n document.removeEventListener 'pointerup', onUp\n document.addEventListener 'pointerup', onUp\n\n _onDecDown: (e) ->\n return if @disabled or @readOnly or e.button isnt 0\n e.preventDefault()\n @_input?.focus()\n @_startRepeat -1, e\n onUp = =>\n @_stopRepeat()\n document.removeEventListener 'pointerup', onUp\n document.addEventListener 'pointerup', onUp\n\n _onKeydown: (e) ->\n return if @disabled or @readOnly\n amount = @_stepAmount(e)\n switch e.key\n when 'ArrowUp'\n e.preventDefault()\n @increment(amount)\n @emit 'change', @value\n when 'ArrowDown'\n e.preventDefault()\n @decrement(amount)\n @emit 'change', @value\n when 'PageUp'\n e.preventDefault()\n @increment(@largeStep)\n @emit 'change', @value\n when 'PageDown'\n e.preventDefault()\n @decrement(@largeStep)\n @emit 'change', @value\n when 'Home'\n if @min?\n e.preventDefault()\n @value = @min\n @emit 'change', @value\n when 'End'\n if @max?\n e.preventDefault()\n @value = @max\n @emit 'change', @value\n\n _onBlur: ->\n val = parseFloat @_input?.value\n unless isNaN(val)\n @value = @_clamp(@_roundToStep(val))\n @emit 'change', @value\n\n _ready := false\n\n mounted: -> _ready = true\n\n ~>\n return unless _ready\n @_input?.value = String(@value)\n\n beforeUnmount: -> @_stopRepeat()\n\n render\n div role: \"group\", $disabled: @disabled?!, $readonly: @readOnly?!\n button aria-label: \"Decrease\", tabIndex: -1\n $decrement: true\n aria-controls: _id\n disabled: @disabled or (@min? and @value <= @min)\n @pointerdown: @_onDecDown\n\n input ref: \"_input\", id: _id, type: \"text\", inputmode: \"numeric\"\n name: @name?!\n aria-roledescription: \"Number field\"\n aria-valuenow: @value\n aria-valuemin: @min?!\n aria-valuemax: @max?!\n aria-disabled: @disabled?!\n aria-readonly: @readOnly?!\n disabled: @disabled\n readonly: @readOnly\n @keydown: @_onKeydown\n @blur: @_onBlur\n\n button aria-label: \"Increase\", tabIndex: -1\n $increment: true\n aria-controls: _id\n disabled: @disabled or (@max? and @value >= @max)\n @pointerdown: @_onIncDown\n",
30
+ "_pkg/ui/otp-field.rip": "# OTPField — accessible headless one-time password input\n#\n# Multi-digit code input with auto-advance, backspace navigation, and\n# paste support. Each digit gets its own input box. Ships zero CSS.\n#\n# Usage:\n# OTPField length: 6, value <=> code, @complete: handleVerify\n\nexport OTPField = component\n @length := 6\n @value := \"\"\n @disabled := false\n @mask := false\n\n _id =! \"otp-#{Math.random().toString(36).slice(2, 8)}\"\n\n _getInputs: ->\n return [] unless @_root\n Array.from(@_root.querySelectorAll('input'))\n\n _focusAt: (idx) ->\n inputs = @_getInputs()\n inputs[idx]?.focus()\n inputs[idx]?.select()\n\n _updateValue: ->\n inputs = @_getInputs()\n digits = inputs.map (el) -> el.value\n @value = digits.join('')\n @emit 'input', @value\n if @value.length is @length and digits.every (d) -> d.length is 1\n @emit 'complete', @value\n\n _onInput: (e, idx) ->\n ch = e.target.value.slice(-1)\n e.target.value = ch\n @_updateValue()\n @_focusAt(idx + 1) if ch and idx < @length - 1\n\n _onKeydown: (e, idx) ->\n switch e.key\n when 'Backspace'\n if not e.target.value and idx > 0\n @_focusAt(idx - 1)\n inputs = @_getInputs()\n inputs[idx - 1]?.value = ''\n @_updateValue()\n when 'ArrowLeft'\n e.preventDefault()\n @_focusAt(idx - 1) if idx > 0\n when 'ArrowRight'\n e.preventDefault()\n @_focusAt(idx + 1) if idx < @length - 1\n when 'Home'\n e.preventDefault()\n @_focusAt(0)\n when 'End'\n e.preventDefault()\n @_focusAt(@length - 1)\n\n _onPaste: (e) ->\n e.preventDefault()\n text = (e.clipboardData?.getData('text') or '').replace(/\\D/g, '').slice(0, @length)\n return unless text\n inputs = @_getInputs()\n for ch, idx in text.split('')\n inputs[idx]?.value = ch\n @_updateValue()\n @_focusAt(Math.min(text.length, @length - 1))\n\n _onFocus: (e) -> e.target.select()\n\n render\n div ref: \"_root\", role: \"group\", aria-label: \"One-time password\"\n $disabled: @disabled?!\n $complete: (@value.length is @length)?!\n for idx in [0...@length]\n input id: \"#{_id}-#{idx}\"\n type: if @mask then \"password\" else \"text\"\n inputmode: \"numeric\"\n autocomplete: \"one-time-code\"\n maxlength: \"1\"\n aria-label: \"Digit #{idx + 1} of #{@length}\"\n disabled: @disabled\n $filled: (@value[idx])?!\n @input: (e) => @_onInput(e, idx)\n @keydown: (e) => @_onKeydown(e, idx)\n @paste: @_onPaste\n @focus: @_onFocus\n",
31
+ "_pkg/ui/badge.rip": "# Badge — accessible headless inline label\n#\n# Decorative label for status, counts, or categories.\n# Ships zero CSS.\n#\n# Usage:\n# Badge \"New\"\n# Badge variant: \"outline\", \"Beta\"\n\nexport Badge = component\n @variant := \"solid\"\n\n render\n span $variant: @variant\n slot\n",
32
+ "_pkg/ui/separator.rip": "# Separator — accessible headless visual divider\n#\n# Decorative or semantic separator between content sections.\n# Ships zero CSS.\n#\n# Usage:\n# Separator\n# Separator orientation: \"vertical\"\n\nexport Separator = component\n @orientation := \"horizontal\"\n @decorative := true\n\n render\n div role: (if @decorative then 'none' else 'separator')\n aria-orientation: (if @orientation is 'vertical' then 'vertical' else undefined)\n $orientation: @orientation\n",
33
+ "_pkg/ui/table.rip": "# Table — accessible headless semantic table wrapper\n#\n# Lightweight wrapper for HTML tables with optional caption and\n# striped rows. For data-heavy tables with virtual scrolling, use Grid.\n# Ships zero CSS.\n#\n# Usage:\n# Table caption: \"Team members\", striped: true\n# thead\n# tr\n# th \"Name\"\n# th \"Role\"\n# tbody\n# tr\n# td \"Alice\"\n# td \"Engineer\"\n\nexport Table = component\n @caption := \"\"\n @striped := false\n\n render\n div $striped: @striped?!\n table\n if @caption\n caption @caption\n slot\n",
34
+ "_pkg/ui/skeleton.rip": "# Skeleton — accessible headless loading placeholder\n#\n# Placeholder element shown while content is loading.\n# Exposes dimensions as CSS custom properties for styling.\n# Ships zero CSS.\n#\n# Usage:\n# Skeleton\n# Skeleton width: \"200px\", height: \"1em\"\n# Skeleton circle: true, width: \"48px\"\n\nexport Skeleton = component\n @width := null\n @height := null\n @circle := false\n @label := \"Loading\"\n\n render\n div role: \"status\", aria-busy: \"true\", aria-label: @label\n style: \"--skeleton-width: #{@width or 'auto'}; --skeleton-height: #{@height or 'auto'}\"\n $circle: @circle?!\n slot\n",
35
+ "_pkg/ui/autocomplete.rip": "# Autocomplete — accessible headless suggestion input\n#\n# Like Combobox but the input value IS the value (no selection from a list).\n# Suggestions are shown as the user types; selecting a suggestion fills the input.\n# Ships zero CSS.\n#\n# Usage:\n# Autocomplete value <=> city, items: cities, @filter: filterCities\n\nacCollator = new Intl.Collator(undefined, { sensitivity: 'base' })\n\nexport Autocomplete = component\n @value := \"\"\n @items := []\n @placeholder := \"Type to search...\"\n @disabled := false\n\n open := false\n _ready := false\n _popupGuard =! ARIA.popupGuard()\n\n filteredItems ~=\n q = @value.trim()\n return @items unless q\n @items.filter (item) ->\n label = if typeof item is 'string' then item else (item.label or item.name or String(item))\n acCollator.compare(label.slice(0, q.length), q) is 0\n\n _listId =! \"ac-list-#{Math.random().toString(36).slice(2, 8)}\"\n\n _getItems: ->\n return [] unless @_list\n Array.from(@_list.querySelectorAll('[role=\"option\"]'))\n\n _updateHighlight: ->\n idx = @_hlIdx\n opts = @_getItems()\n opts.forEach (el, ndx) ->\n el.id = \"#{@_listId}-opt-#{ndx}\" unless el.id\n el.toggleAttribute 'data-highlighted', ndx is idx\n activeId = if idx >= 0 and opts[idx] then opts[idx].id else undefined\n if @_input\n if activeId then @_input.setAttribute 'aria-activedescendant', activeId\n else @_input.removeAttribute 'aria-activedescendant'\n opts[idx]?.scrollIntoView({ block: 'nearest' })\n\n openMenu: ->\n return unless _popupGuard.canOpen()\n open = true\n @_hlIdx = -1\n @_input?.focus()\n\n close: (restoreFocus = true, blockOpen = false) ->\n open = false\n @_hlIdx = -1\n @_input?.removeAttribute 'aria-activedescendant'\n _popupGuard.block() if blockOpen\n @_input?.focus() if restoreFocus\n\n _applyPlacement: ->\n ARIA.position @_input, @_list, placement: 'bottom start', offset: 2, matchWidth: true\n\n selectIndex: (idx) ->\n item = filteredItems[idx]\n return unless item\n label = if typeof item is 'string' then item else (item.label or item.name or String(item))\n @value = label\n @_input?.value = label\n @emit 'select', item\n @close(true, true)\n\n onInput: (e) ->\n newVal = e.target.value\n return if newVal is @value\n @value = newVal\n open = true\n @_hlIdx = if filteredItems.length > 0 then 0 else -1\n setTimeout (=> @_updateHighlight()), 0\n\n onKeydown: (e) ->\n len = filteredItems.length\n ARIA.listNav e,\n next: => @openMenu() unless open; if len then @_hlIdx = (@_hlIdx + 1) %% len; @_updateHighlight()\n prev: => @openMenu() unless open; if len then @_hlIdx = if @_hlIdx <= 0 then len - 1 else @_hlIdx - 1; @_updateHighlight()\n first: => if len then @_hlIdx = 0; @_updateHighlight()\n last: => if len then @_hlIdx = len - 1; @_updateHighlight()\n select: => @selectIndex(@_hlIdx) if @_hlIdx >= 0\n dismiss: => @close()\n tab: => @close(false, true)\n\n ~>\n return unless _ready\n if @_list\n @_applyPlacement()\n ARIA.bindPopover open, (=> @_list), ((isOpen) => open = isOpen), (=> @_input)\n ARIA.popupDismiss open, (=> @_list), (=> @close(false, true)), [=> @_input]\n\n mounted: ->\n _ready = true\n @_hlIdx = -1\n @_input.value = @value if @_input and @value\n\n render\n . $open: open?!\n\n input ref: \"_input\", role: \"combobox\", type: \"text\"\n autocomplete: \"off\"\n aria-expanded: !!open\n aria-haspopup: \"listbox\"\n aria-autocomplete: \"list\"\n aria-controls: open ? _listId : undefined\n $disabled: @disabled?!\n disabled: @disabled\n placeholder: @placeholder\n @input: @onInput\n @keydown: @onKeydown\n\n div ref: \"_list\"\n id: _listId\n role: \"listbox\"\n popover: \"auto\"\n hidden: not open\n aria-hidden: (open ? undefined : \"true\")\n $open: open?!\n style: \"position:fixed;margin:0;inset:auto\"\n for item, idx in filteredItems\n div role: \"option\", tabIndex: -1\n @click: (=> @selectIndex(idx))\n @mouseenter: (=> @_hlIdx = idx; @_updateHighlight())\n \"#{if typeof item is 'string' then item else (item.label or item.name or String(item))}\"\n",
36
+ "_pkg/ui/context-menu.rip": "# ContextMenu — accessible headless right-click menu\n#\n# Opens on contextmenu event (right-click) over the trigger area.\n# Keyboard navigation matches Menu. Ships zero CSS.\n#\n# Usage:\n# ContextMenu @select: handleAction\n# div $trigger: true\n# p \"Right-click this area\"\n# div $item: \"cut\", \"Cut\"\n# div $item: \"copy\", \"Copy\"\n# div $item: \"paste\", \"Paste\"\n\nexport ContextMenu = component\n @disabled := false\n\n open := false\n highlightedIndex := -1\n posX := 0\n posY := 0\n\n _menuItems ~=\n return [] unless @_slot\n Array.from(@_slot.querySelectorAll('[data-item]') or [])\n\n _triggerEl ~=\n return null unless @_slot\n @_slot.querySelector('[data-trigger]')\n\n _onContextMenu: (e) ->\n return if @disabled\n e.preventDefault()\n posX = e.clientX\n posY = e.clientY\n open = true\n highlightedIndex = 0\n requestAnimationFrame =>\n @_list?.querySelectorAll('[role=\"menuitem\"]')[0]?.focus()\n\n close: ->\n open = false\n highlightedIndex = -1\n\n selectIndex: (idx) ->\n item = _menuItems[idx]\n return unless item\n return if item.dataset.disabled?\n @emit 'select', item.dataset.item\n @close()\n\n _focusItem: (idx) ->\n highlightedIndex = idx\n @_list?.querySelectorAll('[role=\"menuitem\"]')[idx]?.focus()\n\n _onKeydown: (e) ->\n return unless _menuItems.length\n len = _menuItems.length\n ARIA.listNav e,\n next: => @_focusItem((highlightedIndex + 1) %% len)\n prev: => @_focusItem((highlightedIndex - 1) %% len)\n first: => @_focusItem(0)\n last: => @_focusItem(len - 1)\n select: => @selectIndex(highlightedIndex)\n dismiss: => @close()\n tab: => @close()\n\n ~> ARIA.popupDismiss open, (=> @_list), (=> @close())\n\n render\n . @contextmenu: @_onContextMenu\n\n . ref: \"_slot\", style: \"display:none\"\n slot\n\n if _triggerEl\n . innerHTML: _triggerEl.innerHTML\n\n if open\n div ref: \"_list\", role: \"menu\", $open: true, @keydown: @_onKeydown\n style: \"position:fixed;left:#{posX}px;top:#{posY}px;z-index:50\"\n for item, idx in _menuItems\n div role: \"menuitem\", tabIndex: -1\n $highlighted: (idx is highlightedIndex)?!\n $disabled: item.dataset.disabled?!\n $value: item.dataset.item\n @click: (=> @selectIndex(idx))\n @mouseenter: (=> highlightedIndex = idx)\n = item.textContent\n",
37
+ "_pkg/ui/form.rip": "# Form — accessible headless form with validation and submission\n#\n# Wraps native <form> with submit handling, validation state, and\n# loading indicator support. Prevents default submission and emits\n# a 'submit' event. Ships zero CSS.\n#\n# Usage:\n# Form @submit: handleSubmit\n# Field label: \"Name\"\n# Input value <=> name\n# Button\n# \"Submit\"\n\nexport Form = component\n @disabled := false\n\n submitting := false\n submitted := false\n errors := {}\n\n _onSubmit: (e) ->\n e.preventDefault()\n return if @disabled or submitting\n submitting = true\n submitted = true\n @emit 'submit', { form: e.target }\n\n setErrors: (errs) ->\n errors = errs or {}\n\n setSubmitting: (val) ->\n submitting = val\n\n render\n form @submit: @_onSubmit, novalidate: true\n $disabled: @disabled?!\n $submitting: submitting?!\n $submitted: submitted?!\n slot\n",
38
+ "_pkg/ui/carousel.rip": "# Carousel — accessible headless slide carousel\n#\n# Displays one slide at a time with arrow key navigation, optional\n# autoplay, and loop mode. Discovers slides from [data-slide] children.\n# Ships zero CSS.\n#\n# Usage:\n# Carousel loop: true\n# div $slide: true\n# img src: \"slide1.jpg\"\n# div $slide: true\n# img src: \"slide2.jpg\"\n# div $slide: true\n# img src: \"slide3.jpg\"\n#\n# Carousel autoplay: true, interval: 5000, @change: handleSlide\n# div $slide: true, \"Slide A\"\n# div $slide: true, \"Slide B\"\n\nexport Carousel = component\n @orientation := \"horizontal\"\n @loop := false\n @autoplay := false\n @interval := 4000\n @label := \"Carousel\"\n\n activeIndex := 0\n _ready := false\n _timer = null\n\n _slides ~=\n return [] unless _ready\n return [] unless @_content\n Array.from(@_content.querySelectorAll('[data-slide]') or [])\n\n totalSlides ~= _slides.length\n\n mounted: ->\n _ready = true\n @_startAutoplay() if @autoplay\n\n beforeUnmount: ->\n @_stopAutoplay()\n\n _startAutoplay: ->\n @_stopAutoplay()\n _timer = setInterval (=> @next()), @interval\n\n _stopAutoplay: ->\n clearInterval _timer if _timer\n _timer = null\n\n goto: (idx) ->\n count = totalSlides\n return unless count\n if @loop\n idx = idx %% count\n else\n idx = Math.max(0, Math.min(idx, count - 1))\n activeIndex = idx\n @emit 'change', activeIndex\n\n next: -> @goto(activeIndex + 1)\n prev: -> @goto(activeIndex - 1)\n\n onKeydown: (e) ->\n horiz = @orientation is 'horizontal'\n switch e.key\n when (if horiz then 'ArrowRight' else 'ArrowDown')\n e.preventDefault()\n @next()\n when (if horiz then 'ArrowLeft' else 'ArrowUp')\n e.preventDefault()\n @prev()\n when 'Home'\n e.preventDefault()\n @goto(0)\n when 'End'\n e.preventDefault()\n @goto(totalSlides - 1)\n\n ~>\n return unless _ready\n _slides.forEach (el, idx) =>\n isActive = idx is activeIndex\n el.hidden = not isActive\n el.toggleAttribute 'data-active', isActive\n el.setAttribute 'role', 'tabpanel'\n el.setAttribute 'aria-roledescription', 'slide'\n el.setAttribute 'aria-label', \"Slide #{idx + 1} of #{totalSlides}\"\n\n onMouseenter: -> @_stopAutoplay() if @autoplay\n onMouseleave: -> @_startAutoplay() if @autoplay\n\n render\n div role: \"region\", aria-roledescription: \"carousel\", aria-label: @label, tabIndex: 0\n $orientation: @orientation\n @keydown: @onKeydown\n @mouseenter: @onMouseenter\n @mouseleave: @onMouseleave\n button $prev: true, aria-label: \"Previous slide\"\n disabled: not @loop and activeIndex <= 0\n $disabled: (not @loop and activeIndex <= 0)?!\n @click: (=> @prev())\n div ref: \"_content\"\n slot\n button $next: true, aria-label: \"Next slide\"\n disabled: not @loop and activeIndex >= totalSlides - 1\n $disabled: (not @loop and activeIndex >= totalSlides - 1)?!\n @click: (=> @next())\n",
39
+ "_pkg/ui/spinner.rip": "# Spinner — accessible headless loading indicator\n#\n# Announces loading state to screen readers via role=\"status\".\n# Exposes size as a CSS custom property for styling.\n# Ships zero CSS.\n#\n# Usage:\n# Spinner\n# Spinner label: \"Saving...\", size: \"24px\"\n\nexport Spinner = component\n @label := \"Loading\"\n @size := null\n\n render\n div role: \"status\", aria-label: @label\n style: if @size then \"--spinner-size: #{@size}\" else undefined\n",
40
+ "_pkg/ui/collapsible.rip": "# Collapsible — accessible headless expand/collapse section\n#\n# Single open/close section. Simpler than Accordion (no item IDs,\n# no single/multiple mode). Exposes content dimensions as CSS\n# custom properties for animated expand/collapse. Ships zero CSS.\n#\n# Usage:\n# Collapsible open <=> isOpen\n# button $trigger: true, \"Show details\"\n# div $content: true\n# p \"Hidden content here\"\n\nexport Collapsible = component\n @open := false\n @disabled := false\n\n _ready := false\n\n mounted: ->\n _ready = true\n trigger = @_root?.querySelector('[data-trigger]')\n return unless trigger\n trigger.addEventListener 'click', => @toggle() unless @disabled\n trigger.addEventListener 'keydown', (e) =>\n if e.key in ['Enter', ' '] and not @disabled\n e.preventDefault()\n @toggle()\n\n toggle: ->\n @open = not @open\n @emit 'change', @open\n\n ~>\n return unless _ready\n trigger = @_root?.querySelector('[data-trigger]')\n content = @_root?.querySelector('[data-content]')\n if trigger\n trigger.setAttribute 'aria-expanded', !!@open\n if @disabled then trigger.setAttribute 'aria-disabled', true else trigger.removeAttribute 'aria-disabled'\n trigger.tabIndex = if @disabled then -1 else 0\n if content\n content.hidden = not @open\n if @open\n rect = content.getBoundingClientRect()\n content.style.setProperty '--collapsible-height', \"#{rect.height}px\"\n content.style.setProperty '--collapsible-width', \"#{rect.width}px\"\n\n render\n div ref: \"_root\", $open: @open?!, $disabled: @disabled?!\n slot\n",
41
+ "_pkg/ui/checkbox.rip": "# Checkbox — accessible headless checkbox/switch widget\n#\n# Toggles on click, Enter, or Space. Supports indeterminate state.\n# Exposes $checked, $indeterminate, $disabled. Ships zero CSS.\n# Set @switch to true for switch semantics (role=\"switch\").\n#\n# Usage:\n# Checkbox checked <=> isActive, @change: handleChange\n# span \"Enable notifications\"\n#\n# Checkbox checked <=> isDark, switch: true\n# span \"Dark mode\"\n\nexport Checkbox = component\n @checked := false\n @disabled := false\n @indeterminate := false\n @switch := false\n\n onClick: ->\n return if @disabled\n @indeterminate = false\n @checked = not @checked\n @emit 'change', @checked\n\n render\n button role: @switch ? 'switch' : 'checkbox'\n aria-checked: @indeterminate ? 'mixed' : !!@checked\n aria-disabled: @disabled?!\n $checked: @checked?!\n $indeterminate: @indeterminate?!\n $disabled: @disabled?!\n slot\n",
42
+ "_pkg/ui/select.rip": "# Select — accessible headless select widget\n#\n# Keyboard: ArrowDown/Up to navigate, Enter/Space to select, Escape to close,\n# Home/End for first/last, typeahead to jump by character.\n# Pointer: mouse/pen opens on pointerdown so release can land on an option in\n# one gesture; touch opens on click so scrolling can still be canceled.\n#\n# Exposes $open and $placeholder on button, $highlighted and $selected on options.\n# Uses native Popover API in manual mode so pointerdown open does not get\n# auto-dismissed by the same click lifecycle.\n# Ships zero CSS — style entirely via attribute selectors in your stylesheet.\n#\n# Usage:\n# Select value <=> selectedValue, @change: (=> handle(event.detail))\n# option value: \"a\", \"Option A\"\n# option value: \"b\", \"Option B\"\n\nexport Select = component\n @value := null\n @placeholder := \"Select...\"\n @disabled := false\n\n options := []\n open := false\n highlightedIndex := -1\n typeaheadBuffer := ''\n typeaheadTimer := null\n suppressTriggerClick := false\n suppressOptionClick := false\n _ready := false\n _popupGuard =! ARIA.popupGuard()\n _listId =! \"sel-#{Math.random().toString(36).slice(2, 8)}\"\n\n getOpt: (o) -> o.dataset.value ?? o.value\n\n _readOptions: ->\n options = Array.from(@_slot?.querySelectorAll('[data-value], option[value]') or [])\n\n selectedLabel ~=\n if @value?\n el = options.find (o) -> @getOpt(o) is String(@value)\n el?.textContent?.trim() or String(@value)\n else\n @placeholder\n\n openMenu: ->\n return unless _popupGuard.canOpen()\n @_readOptions()\n open = true\n highlightedIndex = Math.max(0, options.findIndex (o) -> @getOpt(o) is String(@value))\n requestAnimationFrame => @_focusHighlighted()\n\n close: (restoreFocus = true, blockOpen = false) ->\n open = false\n highlightedIndex = -1\n _popupGuard.block() if blockOpen\n @_trigger?.focus() if restoreFocus\n\n isDisabled: (opt) -> opt?.hasAttribute?('data-disabled') or opt?.disabled\n\n selectIndex: (idx) ->\n opt = options[idx]\n return unless opt\n return if @isDisabled(opt)\n @value = @getOpt(opt)\n @emit 'change', @value\n @close(true, true)\n\n onTriggerKeydown: (e) ->\n return if @disabled\n switch e.key\n when 'ArrowDown', 'ArrowUp', 'Enter', ' '\n e.preventDefault()\n @openMenu()\n when 'Escape'\n e.preventDefault()\n @close() if open\n\n onTriggerPointerdown: (e) ->\n return if @disabled\n return unless e.button is 0\n return if e.ctrlKey\n return unless e.pointerType in ['mouse', 'pen']\n return if open\n e.preventDefault()\n suppressTriggerClick = true\n @openMenu()\n\n onTriggerClick: (e) ->\n return if @disabled\n if suppressTriggerClick\n suppressTriggerClick = false\n e.preventDefault()\n return\n e.preventDefault()\n if open then @close(false, true) else @openMenu()\n\n onOptionPointerup: (e) ->\n return unless open\n return unless e.button is 0\n return unless e.pointerType in ['mouse', 'pen']\n idx = Number(e.currentTarget.dataset.idx)\n suppressOptionClick = true\n @selectIndex(idx)\n\n onOptionClick: (e) ->\n idx = Number(e.currentTarget.dataset.idx)\n if suppressOptionClick\n suppressOptionClick = false\n e.preventDefault()\n return\n @selectIndex(idx)\n\n _nextEnabled: (from, dir) ->\n len = options.length\n i = from\n loop len\n i = (i + dir) %% len\n return i unless @isDisabled(options[i])\n from\n\n onListKeydown: (e) ->\n return unless options.length\n ARIA.listNav e,\n next: => highlightedIndex = @_nextEnabled(highlightedIndex, 1); @_focusHighlighted()\n prev: => highlightedIndex = @_nextEnabled(highlightedIndex, -1); @_focusHighlighted()\n first: => highlightedIndex = 0; @_focusHighlighted()\n last: => highlightedIndex = options.length - 1; @_focusHighlighted()\n select: => @selectIndex(highlightedIndex)\n dismiss: => @close()\n tab: => @close(false, true)\n char: => @_typeahead(e.key)\n\n _typeahead: (char) ->\n clearTimeout typeaheadTimer if typeaheadTimer\n typeaheadBuffer += char.toLowerCase()\n typeaheadTimer = setTimeout (-> typeaheadBuffer = ''), 500\n idx = options.findIndex (o) -> o.textContent.trim().toLowerCase().startsWith(typeaheadBuffer)\n if idx >= 0\n highlightedIndex = idx\n @_focusHighlighted()\n\n _focusHighlighted: ->\n el = @_list?.querySelectorAll('[role=\"option\"]')?.[highlightedIndex]\n el?.focus()\n el?.scrollIntoView { block: 'nearest' }\n\n _applyPlacement: ->\n ARIA.position @_trigger, @_list, placement: 'bottom start', offset: 4, matchWidth: true\n\n mounted: ->\n _ready = true\n requestAnimationFrame => @_readOptions()\n\n ~>\n return unless _ready\n if @_list\n @_applyPlacement()\n ARIA.bindPopover open, (=> @_list), ((isOpen) => open = isOpen), (=> @_trigger)\n ARIA.popupDismiss open, (=> @_list), (=> @close(false, true)), [=> @_trigger]\n\n render\n .\n\n # Button\n button ref: \"_trigger\", role: \"combobox\"\n aria-expanded: !!open\n aria-haspopup: \"listbox\"\n aria-controls: open ? _listId : undefined\n $open: open?!\n $placeholder: (!@value)?!\n $disabled: @disabled?!\n disabled: @disabled\n @pointerdown: @onTriggerPointerdown\n @click: @onTriggerClick\n @keydown: @onTriggerKeydown\n span selectedLabel\n\n # Hidden slot for reading option definitions\n . ref: \"_slot\", style: \"display:none\"\n slot\n\n # Dropdown listbox\n div ref: \"_list\"\n id: _listId\n role: \"listbox\"\n popover: \"manual\"\n hidden: not open\n aria-hidden: (open ? undefined : \"true\")\n style: \"position:fixed;margin:0;inset:auto\"\n $open: open?!\n @keydown: @onListKeydown\n for opt, idx in options\n div role: \"option\"\n tabIndex: -1\n data-idx: idx\n $value: @getOpt(opt)\n $highlighted: (idx is highlightedIndex)?!\n $selected: (@getOpt(opt) is String(@value))?!\n $disabled: @isDisabled(opt)?!\n aria-selected: @getOpt(opt) is String(@value)\n aria-disabled: @isDisabled(opt)?!\n @pointerup: @onOptionPointerup\n @click: @onOptionClick\n @mouseenter: (=> highlightedIndex = idx)\n = opt.textContent\n",
43
+ "_pkg/ui/input.rip": "# Input — accessible headless input wrapper\n#\n# Tracks focus, validation, and disabled state via data attributes.\n# Ships zero CSS.\n#\n# Usage:\n# Input value <=> name, placeholder: \"Enter name\"\n# Input value <=> email, type: \"email\", required: true\n\nexport Input = component extends input\n @value := \"\"\n @placeholder := \"\"\n @type := \"text\"\n @disabled := false\n @required := false\n\n focused := false\n touched := false\n\n onInput: (e) -> @value = e.target.value\n onFocus: -> focused = true\n onBlur: ->\n focused = false\n touched = true\n\n render\n input type: @type, value: @value, placeholder: @placeholder\n disabled: @disabled\n required: @required\n aria-disabled: @disabled?!\n aria-required: @required?!\n $disabled: @disabled?!\n $focused: focused?!\n $touched: touched?!\n @focusin: @onFocus\n @focusout: @onBlur\n",
44
+ "_pkg/ui/textarea.rip": "# Textarea — accessible headless auto-resizing text area\n#\n# Tracks focus, validation, and disabled state via data attributes.\n# Optional auto-resize adjusts height to fit content. Ships zero CSS.\n#\n# Usage:\n# Textarea value <=> bio, placeholder: \"Tell us about yourself\"\n# Textarea value <=> notes, autoResize: true, rows: 3\n\nexport Textarea = component extends textarea\n @value := \"\"\n @placeholder := \"\"\n @disabled := false\n @required := false\n @rows := 3\n @autoResize := false\n\n focused := false\n touched := false\n\n onInput: (e) ->\n @value = e.target.value\n @_resize(e.target) if @autoResize\n\n onFocus: -> focused = true\n onBlur: ->\n focused = false\n touched = true\n\n _resize: (el) ->\n el.style.height = 'auto'\n el.style.height = \"#{el.scrollHeight}px\"\n\n mounted: ->\n @_resize(@_root) if @autoResize and @value\n\n render\n textarea value: @value, placeholder: @placeholder, rows: @rows\n disabled: @disabled\n required: @required\n aria-disabled: @disabled?!\n aria-required: @required?!\n $disabled: @disabled?!\n $focused: focused?!\n $touched: touched?!\n @input: @onInput\n @focusin: @onFocus\n @focusout: @onBlur\n",
45
+ "_pkg/ui/fieldset.rip": "# Fieldset — accessible headless fieldset with legend\n#\n# Groups related fields with an optional legend. Disables all children\n# when @disabled is set. Ships zero CSS.\n#\n# Usage:\n# Fieldset legend: \"Shipping Address\"\n# Field label: \"Street\"\n# Input value <=> street\n# Field label: \"City\"\n# Input value <=> city\n\nexport Fieldset = component\n @legend := \"\"\n @disabled := false\n\n render\n fieldset disabled: @disabled, $disabled: @disabled?!\n if @legend\n legend $legend: true\n @legend\n slot\n",
46
+ "_pkg/ui/radio-group.rip": "# RadioGroup — accessible headless radio group\n#\n# Exactly one option can be selected. Arrow keys move focus and selection.\n# Ships zero CSS.\n#\n# Usage:\n# RadioGroup value <=> size\n# div $value: \"sm\", \"Small\"\n# div $value: \"md\", \"Medium\"\n# div $value: \"lg\", \"Large\"\n\nexport RadioGroup = component\n @value := null\n @disabled := false\n @orientation := \"vertical\"\n @name := \"\"\n\n _options ~=\n return [] unless @_slot\n Array.from(@_slot.querySelectorAll('[data-value]') or [])\n\n _select: (val) ->\n return if @disabled\n @value = val\n @emit 'change', @value\n\n onKeydown: (e) ->\n radios = @_root?.querySelectorAll('[role=\"radio\"]')\n return unless radios?.length\n focused = Array.from(radios).indexOf(document.activeElement)\n return if focused < 0\n len = radios.length\n move = (idx) => radios[idx]?.focus(); @_select(_options[idx]?.dataset.value)\n ARIA.rovingNav e, {\n next: => move((focused + 1) %% len)\n prev: => move((focused - 1) %% len)\n first: => move(0)\n last: => move(len - 1)\n }, 'both'\n\n render\n div ref: \"_root\", role: \"radiogroup\", aria-orientation: @orientation\n $orientation: @orientation\n $disabled: @disabled?!\n\n . ref: \"_slot\", style: \"display:none\"\n slot\n\n for opt, idx in _options\n button role: \"radio\"\n tabindex: (if (opt.dataset.value is @value) or (@value is null and idx is 0) then \"0\" else \"-1\")\n aria-checked: opt.dataset.value is @value\n $checked: (opt.dataset.value is @value)?!\n $disabled: @disabled?!\n $value: opt.dataset.value\n @click: (=> @_select(opt.dataset.value))\n = opt.textContent\n",
47
+ "_pkg/ui/nav-menu.rip": "# NavigationMenu — accessible headless site navigation\n#\n# Horizontal navigation with optional dropdown sub-menus. Triggers show\n# content on hover or click. Ships zero CSS.\n#\n# Usage:\n# NavigationMenu\n# a $link: true, href: \"/\", \"Home\"\n# div $trigger: \"products\"\n# div $panel: true\n# a href: \"/ui\", \"ui\"\n# a href: \"/tools\", \"Tools\"\n# a $link: true, href: \"/about\", \"About\"\n\nexport NavigationMenu = component\n @orientation := \"horizontal\"\n @hoverDelay := 200\n @hoverCloseDelay := 300\n\n activePanel := null\n _ready := false\n _hoverTimer := null\n _closeTimer := null\n\n _navItems ~=\n return [] unless @_slot\n Array.from(@_slot.children).filter (el) ->\n el.dataset?.link? or el.dataset?.trigger?\n\n mounted: -> _ready = true\n\n beforeUnmount: ->\n clearTimeout _hoverTimer if _hoverTimer\n clearTimeout _closeTimer if _closeTimer\n\n _openPanel: (id) ->\n clearTimeout _closeTimer if _closeTimer\n activePanel = id\n requestAnimationFrame => @_position(id)\n\n _closePanel: ->\n activePanel = null\n\n _scheduleOpen: (id) ->\n clearTimeout _closeTimer if _closeTimer\n _hoverTimer = setTimeout (=> @_openPanel(id)), @hoverDelay\n\n _scheduleClose: ->\n clearTimeout _hoverTimer if _hoverTimer\n _closeTimer = setTimeout (=> @_closePanel()), @hoverCloseDelay\n\n _cancelClose: ->\n clearTimeout _closeTimer if _closeTimer\n\n _position: (id) ->\n ARIA.positionBelow @_root?.querySelector(\"[data-nav-trigger=\\\"#{id}\\\"]\"),\n @_root?.querySelector(\"[data-nav-panel=\\\"#{id}\\\"]\"), 2, false\n\n _onKeydown: (e) ->\n navBtns = @_root?.querySelectorAll('[data-nav-trigger], [data-nav-link]')\n return unless navBtns?.length\n focused = Array.from(navBtns).indexOf(document.activeElement)\n return if focused < 0\n len = navBtns.length\n switch e.key\n when 'ArrowRight'\n e.preventDefault()\n navBtns[(focused + 1) %% len]?.focus()\n when 'ArrowLeft'\n e.preventDefault()\n navBtns[(focused - 1) %% len]?.focus()\n when 'ArrowDown'\n triggerId = document.activeElement?.dataset?.navTrigger\n if triggerId\n e.preventDefault()\n @_openPanel(triggerId)\n @_root?.querySelector(\"[data-nav-panel=\\\"#{triggerId}\\\"] a, [data-nav-panel=\\\"#{triggerId}\\\"] button\")?.focus()\n when 'Escape'\n @_closePanel()\n\n ~>\n return unless _ready\n if activePanel\n onDown = (e) => @_closePanel() unless @_root?.contains(e.target)\n onScroll = => @_position(activePanel)\n document.addEventListener 'mousedown', onDown\n window.addEventListener 'scroll', onScroll, true\n return ->\n document.removeEventListener 'mousedown', onDown\n window.removeEventListener 'scroll', onScroll, true\n\n render\n nav ref: \"_root\", role: \"navigation\", aria-orientation: @orientation\n $orientation: @orientation\n\n . ref: \"_slot\", style: \"display:none\"\n slot\n\n for navItem, nIdx in _navItems\n if navItem.dataset.link?\n a $nav-link: true, href: navItem.getAttribute('href') or '#', tabIndex: 0\n @keydown: @_onKeydown\n = navItem.textContent\n else if navItem.dataset.trigger?\n . style: \"display:inline-block;position:relative\"\n button $nav-trigger: navItem.dataset.trigger, tabIndex: 0\n aria-expanded: activePanel is navItem.dataset.trigger\n $open: (activePanel is navItem.dataset.trigger)?!\n @click: (=> if activePanel is navItem.dataset.trigger then @_closePanel() else @_openPanel(navItem.dataset.trigger))\n @mouseenter: (=> @_scheduleOpen(navItem.dataset.trigger))\n @mouseleave: (=> @_scheduleClose())\n @keydown: @_onKeydown\n = navItem.dataset.trigger\n\n if activePanel is navItem.dataset.trigger\n div $nav-panel: navItem.dataset.trigger, $open: true\n style: \"position:fixed;z-index:50\"\n @mouseenter: (=> @_cancelClose())\n @mouseleave: (=> @_scheduleClose())\n for link, lIdx in Array.from(navItem.querySelectorAll('a, [data-link]'))\n a href: link.getAttribute('href') or '#', tabIndex: 0\n @keydown: (e) =>\n if e.key is 'Escape'\n @_closePanel()\n @_root?.querySelector(\"[data-nav-trigger=\\\"#{navItem.dataset.trigger}\\\"]\")?.focus()\n = link.textContent\n",
48
+ "_pkg/ui/toggle-group.rip": "# ToggleGroup — accessible headless toggle group\n#\n# A set of two-state buttons where one or more can be pressed.\n# Set @multiple to false for single-select (radio-like) behavior.\n# Ships zero CSS.\n#\n# Usage:\n# ToggleGroup value <=> alignment\n# div $value: \"left\", \"Left\"\n# div $value: \"center\", \"Center\"\n# div $value: \"right\", \"Right\"\n\nexport ToggleGroup = component\n @value := null\n @disabled := false\n @multiple := false\n @orientation := \"horizontal\"\n\n _items ~=\n return [] unless @_slot\n Array.from(@_slot.querySelectorAll('[data-value]') or [])\n\n _isPressed: (item) ->\n val = item.dataset.value\n if @multiple\n Array.isArray(@value) and val in @value\n else\n val is @value\n\n _toggle: (val) ->\n return if @disabled\n if @multiple\n arr = if Array.isArray(@value) then [...@value] else []\n if val in arr\n arr = arr.filter (v) -> v isnt val\n else\n arr.push val\n @value = arr\n else\n @value = if val is @value then null else val\n @emit 'change', @value\n\n _buttons: ->\n Array.from(@_root?.querySelectorAll('button[aria-pressed]') or [])\n\n _syncTabStops: (focusIdx = null) ->\n buttons = @_buttons()\n return unless buttons.length\n idx = focusIdx\n if idx is null\n idx = buttons.indexOf(document.activeElement)\n if idx < 0\n idx = buttons.findIndex (btn) -> btn.getAttribute('aria-pressed') is 'true'\n idx = 0 if idx < 0\n buttons.forEach (btn, i) -> btn.tabIndex = if i is idx then 0 else -1\n\n _focusIndex: (idx) ->\n buttons = @_buttons()\n return unless buttons.length\n idx = Math.max(0, Math.min(idx, buttons.length - 1))\n @_syncTabStops(idx)\n buttons[idx]?.focus()\n\n onKeydown: (e) ->\n opts = @_buttons()\n return unless opts?.length\n focused = Array.from(opts).indexOf(document.activeElement)\n return if focused < 0\n len = opts.length\n ARIA.rovingNav e, {\n next: => @_focusIndex((focused + 1) %% len)\n prev: => @_focusIndex((focused - 1) %% len)\n first: => @_focusIndex(0)\n last: => @_focusIndex(len - 1)\n }, @orientation\n\n onFocusin: (e) ->\n buttons = @_buttons()\n idx = buttons.indexOf(e.target)\n @_syncTabStops(idx) if idx >= 0\n\n mounted: ->\n requestAnimationFrame => @_syncTabStops()\n\n ~> @_syncTabStops()\n\n render\n div ref: \"_root\", role: \"group\", aria-orientation: @orientation\n $orientation: @orientation\n $disabled: @disabled?!\n\n . ref: \"_slot\", style: \"display:none\"\n slot\n\n for item, idx in _items\n button tabIndex: -1\n aria-pressed: !!@_isPressed(item)\n $pressed: @_isPressed(item)?!\n $disabled: @disabled?!\n $value: item.dataset.value\n @click: (=> @_toggle(item.dataset.value))\n = item.textContent\n",
49
+ "_pkg/ui/breadcrumb.rip": "# Breadcrumb — accessible headless navigation breadcrumb\n#\n# Renders a navigation trail with separator between items.\n# The last item is automatically marked as the current page.\n# Ships zero CSS.\n#\n# Usage:\n# Breadcrumb\n# a $item: true, href: \"/\", \"Home\"\n# a $item: true, href: \"/products\", \"Products\"\n# span $item: true, \"Widget Pro\"\n#\n# Breadcrumb separator: \">\"\n# a $item: true, href: \"/\", \"Home\"\n# span $item: true, \"Settings\"\n\nexport Breadcrumb = component\n @separator := \"/\"\n @label := \"Breadcrumb\"\n\n _ready := false\n\n mounted: -> _ready = true\n\n _items ~=\n return [] unless _ready\n return [] unless @_content\n Array.from(@_content.querySelectorAll('[data-item]') or [])\n\n ~>\n return unless _ready\n items = _items\n return unless items.length\n @_content?.style.setProperty '--breadcrumb-separator', JSON.stringify(@separator)\n items.forEach (el, idx) =>\n isLast = idx is items.length - 1\n if isLast\n el.setAttribute 'aria-current', 'page'\n el.toggleAttribute 'data-current', true\n else\n el.removeAttribute 'aria-current'\n el.removeAttribute 'data-current'\n\n render\n nav aria-label: @label\n ol ref: \"_content\"\n slot\n",
50
+ "_pkg/ui/native-select.rip": "# NativeSelect — accessible headless native select wrapper\n#\n# Wraps a native <select> element with state tracking via data attributes.\n# Use when the browser's built-in dropdown is preferred. Ships zero CSS.\n#\n# Usage:\n# NativeSelect value <=> role, @change: handleChange\n# option value: \"\", \"Choose a role...\"\n# option value: \"admin\", \"Admin\"\n# option value: \"user\", \"User\"\n\nexport NativeSelect = component\n @value := \"\"\n @disabled := false\n @required := false\n\n focused := false\n\n onChange: (e) ->\n e.isTrusted or return\n @value = e.target.value\n @emit 'change', @value\n\n render\n select value: @value, disabled: @disabled, required: @required\n aria-disabled: @disabled?!\n aria-required: @required?!\n $disabled: @disabled?!\n $focused: focused?!\n @change: @onChange\n @focusin: (=> focused = true)\n @focusout: (=> focused = false)\n slot\n",
51
+ "_pkg/ui/dialog.rip": "# Dialog — accessible headless modal dialog\n#\n# Native `<dialog>` variant that uses `showModal()` for top-layer modality.\n# Restores focus to the previously focused element on close.\n# Auto-wires aria-labelledby (first h1-h6) and aria-describedby (first p).\n#\n# Exposes $open on the backdrop. Ships zero CSS.\n#\n# Usage:\n# Dialog open <=> showDialog, @close: handleClose\n# h2 \"Title\"\n# p \"Content\"\n# button @click: (=> showDialog = false), \"Close\"\n\nexport Dialog = component\n @open := false\n @dismissable := true\n @initialFocus := null\n\n _id =! \"dlg-#{Math.random().toString(36).slice(2, 8)}\"\n _focusRaf = null\n\n beforeUnmount: ->\n cancelAnimationFrame _focusRaf if _focusRaf\n _focusRaf = null\n\n ~>\n # ARIA.bindDialog handles the native cancel/close events and toggles\n # @open via setOpen — that's the SOLE close-emission path. The\n # onKeydown handler does NOT additionally call @close() because that\n # would emit `close` twice for the same Escape press (once from\n # native cancel via bindDialog, once from our handler).\n ARIA.bindDialog @open, (=> @_dialog), ((isOpen) =>\n if not isOpen and @open\n @open = false\n @emit 'close'\n ), @dismissable\n\n ~>\n if @open\n ARIA.lockScroll(this)\n # Track the RAF handle so unmount/close-during-open-transition can\n # cancel it. Without this, fast close-then-unmount would focus\n # detached content (and possibly throw on some browsers).\n _focusRaf = requestAnimationFrame =>\n _focusRaf = null\n panel = @_dialog\n if panel\n ARIA.wireAria panel, _id\n if @initialFocus\n target = if typeof @initialFocus is 'string' then panel.querySelector(@initialFocus) else @initialFocus\n target?.focus()\n else\n panel.querySelectorAll('a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex=\"-1\"])')?[0]?.focus()\n return ->\n cancelAnimationFrame _focusRaf if _focusRaf\n _focusRaf = null\n ARIA.unlockScroll(this)\n\n close: ->\n @open = false\n\n onKeydown: (e) ->\n # Don't call @close() here — that would double-emit `close`. Just\n # let the native dialog's `cancel` event flow through bindDialog,\n # which handles preventDefault + setOpen + emit.\n if e.key is 'Escape' and not @dismissable\n e.preventDefault()\n\n onBackdropClick: (e) ->\n if e.target is e.currentTarget and @dismissable\n @_dialog?.close()\n\n render\n dialog ref: \"_dialog\"\n hidden: not @open\n aria-hidden: (@open ? undefined : \"true\")\n @click: @onBackdropClick\n @keydown: @onKeydown\n $open: @open?!\n slot\n",
52
+ "_pkg/ui/menu.rip": "# Menu — accessible headless dropdown menu\n#\n# Keyboard: ArrowDown/Up to navigate, Enter/Space to select, Escape to close,\n# Home/End for first/last. Exposes $open on menu, $highlighted on items.\n# Uses native `popover=\"auto\"` + anchor positioning. Ships zero CSS.\n#\n# Usage:\n# Menu\n# span \"Actions\"\n# div $item: \"edit\", \"Edit\"\n# div $item: \"delete\", \"Delete\"\n# div $item: \"archive\", \"Archive\"\n\nexport Menu = component\n @disabled := false\n\n open := false\n highlightedIndex := -1\n typeaheadBuffer := ''\n typeaheadTimer := null\n _ready := false\n _id =! \"menu-#{Math.random().toString(36).slice(2, 8)}\"\n\n items ~=\n return [] unless @_slot\n Array.from(@_slot.querySelectorAll('[data-item]') or [])\n\n triggerLabel ~=\n return '' unless @_slot\n el = @_slot.querySelector(':not([data-item])')\n el?.textContent?.trim() or ''\n\n toggle: ->\n return if @disabled\n if open then @close() else @openMenu()\n\n openMenu: ->\n open = true\n highlightedIndex = 0\n requestAnimationFrame => @_list?.querySelectorAll('[role=\"menuitem\"]')[0]?.focus()\n\n close: ->\n open = false\n highlightedIndex = -1\n @_trigger?.focus()\n\n selectIndex: (idx) ->\n item = items[idx]\n return unless item\n return if item.dataset.disabled?\n role = item.getAttribute('role')\n if role is 'menuitemcheckbox'\n checked = item.getAttribute('aria-checked') is 'true'\n item.setAttribute 'aria-checked', not checked\n @emit 'select', { id: item.dataset.item, checked: not checked }\n else if role is 'menuitemradio'\n group = item.closest('[data-radio-group]')\n if group\n group.querySelectorAll('[role=\"menuitemradio\"]').forEach (r) ->\n r.setAttribute 'aria-checked', false\n item.setAttribute 'aria-checked', true\n @emit 'select', { id: item.dataset.item, value: item.dataset.item }\n else\n @emit 'select', item.dataset.item\n @close()\n\n _typeahead: (char) ->\n clearTimeout typeaheadTimer if typeaheadTimer\n typeaheadBuffer += char.toLowerCase()\n typeaheadTimer = setTimeout (-> typeaheadBuffer = ''), 500\n idx = items.findIndex (o) -> o.textContent.trim().toLowerCase().startsWith(typeaheadBuffer)\n if idx >= 0\n highlightedIndex = idx\n @_list?.querySelectorAll('[role=\"menuitem\"]')[idx]?.focus()\n\n _applyPlacement: ->\n ARIA.position @_trigger, @_list, placement: 'bottom start', offset: 4\n\n mounted: ->\n _ready = true\n\n onTriggerKeydown: (e) ->\n return if @disabled\n if e.key in ['ArrowDown', 'Enter', ' ']\n e.preventDefault()\n @openMenu()\n\n _focusItem: (idx) ->\n highlightedIndex = idx\n @_list?.querySelectorAll('[role=\"menuitem\"]')[idx]?.focus()\n\n onMenuKeydown: (e) ->\n len = items.length\n return unless len\n ARIA.listNav e,\n next: => @_focusItem((highlightedIndex + 1) %% len)\n prev: => @_focusItem((highlightedIndex - 1) %% len)\n first: => @_focusItem(0)\n last: => @_focusItem(len - 1)\n select: => @selectIndex(highlightedIndex)\n dismiss: => @close()\n tab: => @close()\n char: => @_typeahead(e.key)\n\n ~>\n return unless _ready\n if @_list\n @_list.id = _id\n @_list.setAttribute 'popover', 'auto'\n @_applyPlacement()\n if @_trigger\n @_trigger.setAttribute 'aria-controls', _id\n ARIA.bindPopover open, (=> @_list), ((isOpen) => open = isOpen), (=> @_trigger)\n\n render\n .\n button ref: \"_trigger\"\n aria-haspopup: \"menu\"\n aria-expanded: !!open\n aria-controls: _id\n $open: open?!\n $disabled: @disabled?!\n disabled: @disabled\n @click: @toggle\n @keydown: @onTriggerKeydown\n triggerLabel\n\n . ref: \"_slot\", style: \"display:none\"\n slot\n\n div ref: \"_list\"\n id: _id\n role: \"menu\"\n popover: \"auto\"\n hidden: not open\n aria-hidden: (open ? undefined : \"true\")\n $open: open?!\n style: \"position:fixed;margin:0;inset:auto\"\n @keydown: @onMenuKeydown\n for item, idx in items\n div role: item.getAttribute('role') or 'menuitem'\n tabIndex: -1\n aria-checked: item.getAttribute('aria-checked')?!\n $highlighted: (idx is highlightedIndex)?!\n $disabled: item.dataset.disabled?!\n @click: (=> @selectIndex(idx))\n @mouseenter: (=> highlightedIndex = idx)\n = item.textContent\n",
53
+ "_pkg/ui/multi-select.rip": "# MultiSelect — accessible headless multi-select with chips\n#\n# Filterable dropdown where multiple items can be selected. Selected items\n# appear as removable chips. Type to filter, click or arrow+Enter to toggle.\n# Ships zero CSS — style entirely via attribute selectors in your stylesheet.\n#\n# Usage:\n# MultiSelect value <=> selectedColors, items: colors, placeholder: \"Choose colors...\"\n\nexport MultiSelect = component\n @value := []\n @items := []\n @placeholder := \"Select...\"\n @disabled := false\n\n open := false\n query := ''\n highlightedIndex := -1\n _ready := false\n _ignoreInputClickOnce := false\n _popupGuard =! ARIA.popupGuard()\n _listId =! \"ms-#{Math.random().toString(36).slice(2, 8)}\"\n\n filtered ~=\n q = query.trim().toLowerCase()\n return @items unless q\n @items.filter (item) ->\n label = if typeof item is 'string' then item else (item.label or item.name or String(item))\n label.toLowerCase().includes(q)\n\n # Block reopen briefly so the same pointer gesture that closed the popup\n # cannot immediately reopen it via focus/click side effects.\n _blockOpenBriefly: ->\n _popupGuard.block()\n\n _canOpen: ->\n _popupGuard.canOpen()\n\n _label: (item) ->\n if typeof item is 'string' then item else (item.label or item.name or String(item))\n\n _val: (item) ->\n if typeof item is 'string' then item else (item.value or item.id or String(item))\n\n # Look up the original item by stored value so chip rendering can use\n # the friendly label even when @items are objects. Falls back to the\n # value itself when no matching item is found (stale value, free-form).\n _itemByVal: (v) ->\n return v unless Array.isArray(@items)\n for item in @items\n return item if @_val(item) is v\n v\n\n _chipLabel: (v) -> @_label(@_itemByVal(v))\n\n _isSelected: (item) ->\n v = @_val(item)\n Array.isArray(@value) and v in @value\n\n _toggleItem: (item) ->\n return if @disabled\n v = @_val(item)\n arr = if Array.isArray(@value) then [...@value] else []\n if v in arr\n arr = arr.filter (x) -> x isnt v\n else\n arr.push v\n @value = arr\n @emit 'change', @value\n @_close(true, true)\n\n _removeChip: (v) ->\n return if @disabled\n @value = @value.filter (x) -> x isnt v\n @emit 'change', @value\n\n _onRemoveMousedown: (e) ->\n e.preventDefault()\n e.stopPropagation()\n @_blockOpenBriefly()\n\n _onInput: (e) ->\n query = e.target.value\n open = true\n highlightedIndex = if filtered.length > 0 then 0 else -1\n\n _openMenu: ->\n return if @disabled\n return unless @_canOpen()\n open = true\n highlightedIndex = if filtered.length > 0 then Math.max(highlightedIndex, 0) else -1\n\n _close: (restoreFocus = false, blockOpen = false) ->\n open = false\n query = ''\n highlightedIndex = -1\n @_blockOpenBriefly() if blockOpen\n @_input?.focus() if restoreFocus\n\n onFocusin: ->\n return unless @_canOpen()\n @_openMenu()\n\n _onInputMousedown: (e) ->\n return unless open and not query\n e.preventDefault()\n _ignoreInputClickOnce = true\n @_close(false, true)\n\n _onInputClick: ->\n if _ignoreInputClickOnce\n _ignoreInputClickOnce = false\n return\n return unless @_canOpen()\n @_openMenu()\n\n _onChipsClick: (e) ->\n return unless e.target is e.currentTarget\n if open and not query\n @_close(false, true)\n return\n return unless @_canOpen()\n @_input?.focus()\n @_openMenu() if document.activeElement is @_input\n\n onFocusout: ->\n setTimeout =>\n return if @_content?.contains(document.activeElement)\n @_close(false, true)\n , 0\n\n mounted: ->\n _ready = true\n\n _applyPlacement: ->\n ARIA.position @_content, @_list, placement: 'bottom start', offset: 2, matchWidth: true\n\n _onKeydown: (e) ->\n len = filtered.length\n switch e.key\n when 'ArrowDown'\n e.preventDefault()\n open = true\n highlightedIndex = (highlightedIndex + 1) %% len if len\n when 'ArrowUp'\n e.preventDefault()\n highlightedIndex = (highlightedIndex - 1) %% len if len\n when 'Enter'\n e.preventDefault()\n if highlightedIndex >= 0 and highlightedIndex < len\n @_toggleItem(filtered[highlightedIndex])\n when 'Escape'\n e.preventDefault()\n @_close()\n when 'Backspace'\n # Defensive: @value could be null/undefined if a parent reset it\n # to a non-array value. Only act when query is empty (so\n # Backspace inside text deletes the char) AND there's a chip to\n # remove.\n if not query and Array.isArray(@value) and @value.length > 0\n e.preventDefault()\n @value = @value.slice(0, -1)\n @emit 'change', @value\n when 'Tab'\n @_close(false, true)\n\n ~>\n return unless _ready\n if @_list\n @_list.setAttribute 'popover', 'manual'\n @_applyPlacement()\n # ARIA.combine: both helpers register listeners that need cleanup.\n # Without combine, only the LAST expression's value is taken as the\n # effect's cleanup, so the bindPopover `toggle` listener leaked on\n # every effect re-run.\n ARIA.combine(\n ARIA.bindPopover open, (=> @_list), ((isOpen) => open = isOpen), (=> @_input)\n ARIA.popupDismiss open, (=> @_list), (=> @_close(false, true)), [=> @_input, => @_content]\n )\n\n render\n . ref: \"_content\", $open: open?!, $disabled: @disabled?!\n\n # Chip area + input\n . $chips: true, @click: @_onChipsClick\n for chip in @value\n span $chip: true, $value: chip\n \"#{@_chipLabel(chip)}\"\n button type: \"button\", $remove: true, aria-label: \"Remove #{@_chipLabel(chip)}\", @mousedown: @_onRemoveMousedown, @click: (=> @_removeChip(chip))\n \"✕\"\n input ref: \"_input\", type: \"text\", autocomplete: \"off\"\n role: \"combobox\"\n aria-expanded: !!open\n aria-haspopup: \"listbox\"\n aria-controls: if open then _listId else undefined\n aria-activedescendant: if highlightedIndex >= 0 then \"#{_listId}-#{highlightedIndex}\" else undefined\n $disabled: @disabled?!\n disabled: @disabled\n placeholder: if @value.length is 0 then @placeholder else ''\n value: query\n @mousedown: @_onInputMousedown\n @input: @_onInput\n @click: @_onInputClick\n @keydown: @_onKeydown\n\n # Dropdown\n div ref: \"_list\"\n id: _listId\n role: \"listbox\"\n popover: \"manual\"\n hidden: not open\n aria-hidden: (open ? undefined : \"true\")\n $open: open?!\n aria-multiselectable: \"true\"\n style: \"position:fixed;margin:0;inset:auto\"\n for item, idx in filtered\n div role: \"option\", tabIndex: -1, id: \"#{_listId}-#{idx}\"\n $value: @_val(item)\n $selected: @_isSelected(item)?!\n $highlighted: (idx is highlightedIndex)?!\n aria-selected: !!@_isSelected(item)\n @click: (=> @_toggleItem(item))\n @mouseenter: (=> highlightedIndex = idx)\n @_label(item)\n if filtered.length is 0 and query\n div role: \"status\", aria-live: \"polite\", $empty: true\n \"No results\"\n",
54
+ "_pkg/ui/menubar.rip": "# Menubar — accessible headless horizontal menu bar\n#\n# A horizontal bar of menu triggers. Each trigger opens a dropdown menu.\n# Arrow keys navigate between triggers; open menus close when moving\n# to the next trigger. Ships zero CSS.\n#\n# Usage:\n# Menubar\n# div $menu: \"file\"\n# div $item: \"new\", \"New\"\n# div $item: \"open\", \"Open\"\n# div $item: \"save\", \"Save\"\n# div $menu: \"edit\"\n# div $item: \"undo\", \"Undo\"\n# div $item: \"redo\", \"Redo\"\n\nexport Menubar = component\n @disabled := false\n\n activeMenu := null\n highlightedIndex := -1\n\n _menus ~=\n return [] unless @_slot\n Array.from(@_slot.querySelectorAll('[data-menu]') or [])\n\n _menuItemsFor: (menu) ->\n return [] unless menu\n Array.from(menu.querySelectorAll('[data-item]') or [])\n\n _openMenu: (menuId) ->\n return if @disabled\n activeMenu = menuId\n highlightedIndex = 0\n requestAnimationFrame =>\n @_position(menuId)\n @_root?.querySelector(\"[data-menu-list=\\\"#{menuId}\\\"] [role=\\\"menuitem\\\"]\")?.focus()\n\n _closeMenu: ->\n activeMenu = null\n highlightedIndex = -1\n\n _position: (menuId) ->\n ARIA.positionBelow @_root?.querySelector(\"[data-menu-trigger=\\\"#{menuId}\\\"]\"),\n @_root?.querySelector(\"[data-menu-list=\\\"#{menuId}\\\"]\"), 2, false\n\n selectItem: (menuId, itemId) ->\n @emit 'select', { menu: menuId, item: itemId }\n @_closeMenu()\n @_root?.querySelector(\"[data-menu-trigger=\\\"#{menuId}\\\"]\")?.focus()\n\n _onBarKeydown: (e) ->\n triggers = @_root?.querySelectorAll('[data-menu-trigger]')\n return unless triggers?.length\n focused = Array.from(triggers).indexOf(document.activeElement)\n return if focused < 0\n len = triggers.length\n switch e.key\n when 'ArrowRight'\n e.preventDefault()\n next = (focused + 1) %% len\n triggers[next]?.focus()\n if activeMenu\n @_openMenu(triggers[next]?.dataset.menuTrigger)\n when 'ArrowLeft'\n e.preventDefault()\n prev = (focused - 1) %% len\n triggers[prev]?.focus()\n if activeMenu\n @_openMenu(triggers[prev]?.dataset.menuTrigger)\n when 'ArrowDown', 'Enter', ' '\n e.preventDefault()\n menuId = triggers[focused]?.dataset.menuTrigger\n @_openMenu(menuId) if menuId\n when 'Escape'\n @_closeMenu()\n\n _onMenuKeydown: (e, menuId, menuItems) ->\n len = menuItems.length\n return unless len\n switch e.key\n when 'ArrowDown'\n e.preventDefault()\n highlightedIndex = (highlightedIndex + 1) %% len\n @_root?.querySelector(\"[data-menu-list=\\\"#{menuId}\\\"]\")?.querySelectorAll('[role=\"menuitem\"]')[highlightedIndex]?.focus()\n when 'ArrowUp'\n e.preventDefault()\n highlightedIndex = (highlightedIndex - 1) %% len\n @_root?.querySelector(\"[data-menu-list=\\\"#{menuId}\\\"]\")?.querySelectorAll('[role=\"menuitem\"]')[highlightedIndex]?.focus()\n when 'Enter', ' '\n e.preventDefault()\n item = menuItems[highlightedIndex]\n @selectItem(menuId, item?.dataset.item) if item\n when 'Escape', 'Tab'\n e.preventDefault() if e.key is 'Escape'\n @_closeMenu()\n @_root?.querySelector(\"[data-menu-trigger=\\\"#{menuId}\\\"]\")?.focus()\n when 'ArrowRight'\n e.preventDefault()\n @_closeMenu()\n triggers = @_root?.querySelectorAll('[data-menu-trigger]')\n focused = Array.from(triggers).findIndex (t) -> t.dataset.menuTrigger is menuId\n next = (focused + 1) %% triggers.length\n triggers[next]?.focus()\n @_openMenu(triggers[next]?.dataset.menuTrigger)\n when 'ArrowLeft'\n e.preventDefault()\n @_closeMenu()\n triggers = @_root?.querySelectorAll('[data-menu-trigger]')\n focused = Array.from(triggers).findIndex (t) -> t.dataset.menuTrigger is menuId\n prev = (focused - 1) %% triggers.length\n triggers[prev]?.focus()\n @_openMenu(triggers[prev]?.dataset.menuTrigger)\n\n ~>\n if activeMenu\n onDown = (e) => @_closeMenu() unless @_root?.contains(e.target)\n onScroll = => @_position(activeMenu)\n document.addEventListener 'mousedown', onDown\n window.addEventListener 'scroll', onScroll, true\n return ->\n document.removeEventListener 'mousedown', onDown\n window.removeEventListener 'scroll', onScroll, true\n\n render\n div ref: \"_root\", role: \"menubar\", $disabled: @disabled?!\n\n . ref: \"_slot\", style: \"display:none\"\n slot\n\n for menu in _menus\n button role: \"menuitem\", tabIndex: 0\n \"data-menu-trigger\": menu.dataset.menu\n aria-haspopup: \"menu\"\n aria-expanded: activeMenu is menu.dataset.menu\n $open: (activeMenu is menu.dataset.menu)?!\n @click: (=> if activeMenu is menu.dataset.menu then @_closeMenu() else @_openMenu(menu.dataset.menu))\n @keydown: @_onBarKeydown\n = menu.dataset.menu\n\n if activeMenu is menu.dataset.menu\n div role: \"menu\", $open: true, style: \"position:fixed\"\n \"data-menu-list\": menu.dataset.menu\n @keydown: (e) => @_onMenuKeydown(e, menu.dataset.menu, @_menuItemsFor(menu))\n for item, idx in @_menuItemsFor(menu)\n div role: \"menuitem\", tabIndex: -1\n $highlighted: (idx is highlightedIndex)?!\n $value: item.dataset.item\n @click: (=> @selectItem(menu.dataset.menu, item.dataset.item))\n @mouseenter: (=> highlightedIndex = idx)\n = item.textContent\n",
55
+ "_pkg/ui/avatar.rip": "# Avatar — accessible headless avatar\n#\n# Shows an image, falls back to initials or a generic icon placeholder.\n# Ships zero CSS.\n#\n# Usage:\n# Avatar src: user.photoUrl, alt: user.name, fallback: \"AC\"\n# Avatar fallback: \"JD\"\n# Avatar\n\nexport Avatar = component\n @src := \"\"\n @alt := \"\"\n @fallback := \"\"\n\n imgError := false\n\n _onError: -> imgError = true\n\n _initials ~=\n return @fallback if @fallback\n return '' unless @alt\n parts = @alt.trim().split(/\\s+/)\n chars = parts.map (p) -> p[0]?.toUpperCase() or ''\n chars.slice(0, 2).join('')\n\n render\n span role: \"img\", aria-label: @alt or 'Avatar'\n $status: if @src and not imgError then 'image' else if _initials then 'fallback' else 'placeholder'\n if @src and not imgError\n img src: @src, alt: @alt, @error: @_onError\n else if _initials\n span $initials: true\n _initials\n else\n span $placeholder: true\n \"?\"\n",
56
+ "_pkg/ui/checkbox-group.rip": "# CheckboxGroup — accessible headless checkbox group\n#\n# Multiple options can be checked independently. Wraps individual checkboxes\n# with group semantics. Value is an array of checked option values.\n# Ships zero CSS.\n#\n# Usage:\n# CheckboxGroup value <=> selectedToppings\n# div $value: \"cheese\", \"Cheese\"\n# div $value: \"bacon\", \"Bacon\"\n# div $value: \"lettuce\", \"Lettuce\"\n\nexport CheckboxGroup = component\n @value := []\n @disabled := false\n @orientation := \"vertical\"\n @label := \"\"\n\n _options ~=\n return [] unless @_slot\n Array.from(@_slot.querySelectorAll('[data-value]') or [])\n\n _toggle: (val) ->\n return if @disabled\n arr = if Array.isArray(@value) then [...@value] else []\n if val in arr\n arr = arr.filter (v) -> v isnt val\n else\n arr.push val\n @value = arr\n @emit 'change', @value\n\n _boxes: ->\n Array.from(@_root?.querySelectorAll('[role=\"checkbox\"]') or [])\n\n _syncTabStops: (focusIdx = null) ->\n boxes = @_boxes()\n return unless boxes.length\n idx = focusIdx\n if idx is null\n idx = boxes.indexOf(document.activeElement)\n idx = 0 if idx < 0\n boxes.forEach (box, i) -> box.tabIndex = if i is idx then 0 else -1\n\n _focusIndex: (idx) ->\n boxes = @_boxes()\n return unless boxes.length\n idx = Math.max(0, Math.min(idx, boxes.length - 1))\n @_syncTabStops(idx)\n boxes[idx]?.focus()\n\n onKeydown: (e) ->\n boxes = @_boxes()\n return unless boxes?.length\n focused = Array.from(boxes).indexOf(document.activeElement)\n return if focused < 0\n len = boxes.length\n ARIA.rovingNav e, {\n next: => @_focusIndex((focused + 1) %% len)\n prev: => @_focusIndex((focused - 1) %% len)\n first: => @_focusIndex(0)\n last: => @_focusIndex(len - 1)\n }, @orientation\n\n onFocusin: (e) ->\n boxes = @_boxes()\n idx = boxes.indexOf(e.target)\n @_syncTabStops(idx) if idx >= 0\n\n mounted: ->\n requestAnimationFrame => @_syncTabStops()\n\n ~> @_syncTabStops()\n\n render\n div ref: \"_root\", role: \"group\", aria-label: @label or undefined, aria-orientation: @orientation\n $orientation: @orientation\n $disabled: @disabled?!\n\n . ref: \"_slot\", style: \"display:none\"\n slot\n\n for opt, idx in _options\n button role: \"checkbox\", tabIndex: -1\n aria-checked: opt.dataset.value in @value\n $checked: (opt.dataset.value in @value)?!\n $disabled: @disabled?!\n $value: opt.dataset.value\n @click: (=> @_toggle(opt.dataset.value))\n = opt.textContent\n"
57
+ },
58
+ "data": {
59
+ "title": "Rip UI"
60
+ }
61
+ }
Binary file
@@ -163,12 +163,6 @@ export default function(hljs) {
163
163
  ],
164
164
  };
165
165
 
166
- const MAP_LITERAL = {
167
- className: 'operator',
168
- begin: /\*(?=\{)/,
169
- relevance: 5,
170
- };
171
-
172
166
  const OPERATORS = {
173
167
  className: 'operator',
174
168
  begin: /\|>|::|:=|~=|~>|<=>|\.=|=!|!\?|\?!|=~|\?\?=|\?\?|\?\.|\.\.\.|\.\.|=>|->|\*\*|\/\/|%%|===|!==|==|!=|<=|>=|&&|\|\||[+\-*\/%&|^~<>=!?]/,
@@ -205,7 +199,6 @@ export default function(hljs) {
205
199
  INSTANCE_VAR,
206
200
  SIGIL_ATTR,
207
201
  TYPE_KEYWORDS,
208
- MAP_LITERAL,
209
202
  OPERATORS,
210
203
  { // inline JS (backtick)
211
204
  className: 'string',