@rip-lang/ui 0.3.66 → 0.4.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 (83) hide show
  1. package/AGENTS.md +93 -0
  2. package/README.md +22 -625
  3. package/browser/AGENTS.md +213 -0
  4. package/browser/CONTRIBUTING.md +375 -0
  5. package/browser/README.md +11 -0
  6. package/browser/TESTING.md +59 -0
  7. package/browser/browser.rip +56 -0
  8. package/{components → browser/components}/accordion.rip +1 -1
  9. package/{components → browser/components}/alert-dialog.rip +6 -3
  10. package/{components → browser/components}/autocomplete.rip +27 -21
  11. package/{components → browser/components}/avatar.rip +3 -3
  12. package/{components → browser/components}/badge.rip +1 -1
  13. package/{components → browser/components}/breadcrumb.rip +2 -2
  14. package/{components → browser/components}/button-group.rip +3 -3
  15. package/{components → browser/components}/button.rip +2 -2
  16. package/{components → browser/components}/card.rip +1 -1
  17. package/{components → browser/components}/carousel.rip +5 -5
  18. package/{components → browser/components}/checkbox-group.rip +40 -11
  19. package/{components → browser/components}/checkbox.rip +4 -4
  20. package/{components → browser/components}/collapsible.rip +2 -2
  21. package/{components → browser/components}/combobox.rip +36 -23
  22. package/{components → browser/components}/context-menu.rip +1 -1
  23. package/{components → browser/components}/date-picker.rip +5 -5
  24. package/{components → browser/components}/dialog.rip +8 -4
  25. package/{components → browser/components}/drawer.rip +8 -4
  26. package/{components → browser/components}/editable-value.rip +7 -1
  27. package/{components → browser/components}/field.rip +5 -5
  28. package/{components → browser/components}/fieldset.rip +2 -2
  29. package/{components → browser/components}/form.rip +1 -1
  30. package/{components → browser/components}/grid.rip +8 -8
  31. package/{components → browser/components}/input-group.rip +1 -1
  32. package/{components → browser/components}/input.rip +6 -6
  33. package/{components → browser/components}/label.rip +2 -2
  34. package/{components → browser/components}/menu.rip +17 -10
  35. package/{components → browser/components}/menubar.rip +1 -1
  36. package/{components → browser/components}/meter.rip +7 -7
  37. package/{components → browser/components}/multi-select.rip +76 -33
  38. package/{components → browser/components}/native-select.rip +3 -3
  39. package/{components → browser/components}/nav-menu.rip +3 -3
  40. package/{components → browser/components}/number-field.rip +11 -11
  41. package/{components → browser/components}/otp-field.rip +4 -4
  42. package/{components → browser/components}/pagination.rip +4 -4
  43. package/{components → browser/components}/popover.rip +11 -24
  44. package/{components → browser/components}/preview-card.rip +7 -11
  45. package/{components → browser/components}/progress.rip +3 -3
  46. package/{components → browser/components}/radio-group.rip +4 -4
  47. package/{components → browser/components}/resizable.rip +3 -3
  48. package/{components → browser/components}/scroll-area.rip +1 -1
  49. package/{components → browser/components}/select.rip +55 -27
  50. package/{components → browser/components}/separator.rip +2 -2
  51. package/{components → browser/components}/skeleton.rip +4 -4
  52. package/{components → browser/components}/slider.rip +15 -10
  53. package/{components → browser/components}/spinner.rip +2 -2
  54. package/{components → browser/components}/table.rip +2 -2
  55. package/{components → browser/components}/tabs.rip +12 -7
  56. package/{components → browser/components}/textarea.rip +8 -8
  57. package/{components → browser/components}/toast.rip +3 -3
  58. package/{components → browser/components}/toggle-group.rip +42 -11
  59. package/{components → browser/components}/toggle.rip +2 -2
  60. package/{components → browser/components}/toolbar.rip +2 -2
  61. package/{components → browser/components}/tooltip.rip +19 -23
  62. package/browser/hljs-rip.js +209 -0
  63. package/browser/playwright.config.mjs +31 -0
  64. package/browser/tests/overlays.js +349 -0
  65. package/email/AGENTS.md +16 -0
  66. package/email/README.md +55 -0
  67. package/email/benchmarks/benchmark.rip +94 -0
  68. package/email/benchmarks/samples.rip +104 -0
  69. package/email/compat.rip +129 -0
  70. package/email/components.rip +371 -0
  71. package/email/dom.rip +330 -0
  72. package/email/email.rip +10 -0
  73. package/email/render.rip +82 -0
  74. package/package.json +29 -39
  75. package/shared/README.md +3 -0
  76. package/shared/styles.rip +17 -0
  77. package/tailwind/AGENTS.md +3 -0
  78. package/tailwind/README.md +27 -0
  79. package/tailwind/engine.js +107 -0
  80. package/tailwind/inline.js +215 -0
  81. package/tailwind/serve.js +6 -0
  82. package/tailwind/tailwind.rip +13 -0
  83. package/ui.rip +3 -0
@@ -0,0 +1,213 @@
1
+ # UI Widgets — Agent Guide
2
+
3
+ Accessible headless widgets written in Rip. They expose `$` attributes for styling and compile in the browser with no build step.
4
+
5
+ ## Conventions
6
+
7
+ - use `ref: "_name"` for DOM refs; never `div._name`
8
+ - common ref names: `_trigger`, `_list`, `_content`
9
+ - use `_slot` with `style: "display:none"` to read child definitions
10
+ - auto-wired root handlers use `onKeydown`, `onScroll`, etc.
11
+ - child element handlers use underscore names like `_headerClick`
12
+ - public methods do not use `_`
13
+ - prefer `=!` for constants and `:=` only for state that should trigger updates
14
+ - click-outside should use document `mousedown` cleanup, not backdrop divs
15
+ - dropdowns use `position:fixed;visibility:hidden` first, then `_position()` via `requestAnimationFrame`
16
+ - keep `preventScroll: true` at module scope
17
+ - use reactive arrays directly: `toasts = [...toasts, { message: "Saved!" }]`
18
+ - prefix module-scope lowercase names to avoid shared-scope collisions
19
+
20
+ ## Critical Gotchas
21
+
22
+ - do not shadow prop names with locals inside component methods; matching names rewrite to `this.name.value`
23
+ - always name indices in nested render loops
24
+ - avoid `value: @prop` on `<input>` when numeric parsing matters
25
+ - computed values are read-only; invalidate with a reactive counter
26
+ - use imperative DOM updates for 60fps tracking work like scroll, drag, and resize
27
+ - put side effects in `~>` branches, not only in methods like `close()`
28
+ - bare `x.y` in render blocks is tag syntax; use `= x.y` for text output
29
+ - bare variable names in template blocks are tag names, not text: `editName` creates `<editName>` element. Use `= editName` or `"#{editName}"` for text output
30
+
31
+ ### Reactive Attribute Ownership
32
+
33
+ **The parent owns any attribute it sets on slot children.** If a parent template sets `hidden: true` on a slot child, the reconciler will re-apply `hidden = true` on every parent re-render. A component that imperatively sets `editor.hidden = false` in a `~>` effect will have that overwritten whenever the parent re-renders (e.g. when any reactive state in scope changes).
34
+
35
+ Rule: **a component that manages a DOM property imperatively must be the only one setting it.** Do not set `hidden`, `value`, `checked`, or any other imperatively-managed property on slot children in the parent template. The component's `~>` effect sets the initial state.
36
+
37
+ This is the same principle as React's "controlled vs. uncontrolled" — whoever sets a reactive binding on a render cycle owns it.
38
+
39
+ Practical example — correct:
40
+ ```coffee
41
+ EditableValue
42
+ span $display: true
43
+ "#{editName}"
44
+ div $editor: true ← NO hidden: true here — EditableValue owns it
45
+ input ...
46
+ ```
47
+
48
+ ### Components Emitting Their Own Root Element's Events
49
+
50
+ If a component's root element IS the event source (e.g. `<select>` firing 'change', `<input>` firing 'input'), calling `@emit 'change'` dispatches a `CustomEvent('change', { bubbles: true })` on that same element — which re-triggers the listener in an infinite loop.
51
+
52
+ Guard with `e.isTrusted or return` at the start of the handler. `e.isTrusted` is `false` for synthetic `CustomEvent` dispatches, `true` for real user interactions:
53
+ ```coffee
54
+ onChange: (e) ->
55
+ e.isTrusted or return # prevent infinite loop when @emit re-triggers handler
56
+ @value = e.target.value
57
+ @emit 'change', @value
58
+ ```
59
+
60
+ ### onFocusout and relatedTarget
61
+
62
+ In some browsers, `e.relatedTarget` is `null` even when focus moves to a `tabindex="-1"` element (e.g., an option div being clicked). Checking `e.relatedTarget` directly will incorrectly close the popup before the click fires.
63
+
64
+ Use `setTimeout 0` and `document.activeElement` instead:
65
+ ```coffee
66
+ onFocusout: ->
67
+ setTimeout => @close() unless @_content?.contains(document.activeElement), 0
68
+ ```
69
+
70
+ ### Always Track Reactive Dependencies Before Early Returns
71
+
72
+ In a `~>` effect that has an early return guard, reactive signals read AFTER the guard are not tracked on runs that short-circuit. Always read all signals you need to track before any `return unless`:
73
+ ```coffee
74
+ ~>
75
+ _editing = editing # track BEFORE early return
76
+ display = @_root?.querySelector('[data-display]')
77
+ editor = @_root?.querySelector('[data-editor]')
78
+ return unless display and editor
79
+ editor.hidden = not _editing
80
+ ```
81
+
82
+ ### Event Handler Parameter Syntax
83
+
84
+ Inline event handlers with explicit parameters are unreliable in template attribute contexts. Both `(e) =>` (causes parse error) and `(e) ->` (causes parse error in conditional blocks) can break compilation of the entire component, causing "WidgetGallery is not defined" or similar errors.
85
+
86
+ **The safe approach: use a named method reference.**
87
+
88
+ ```coffee
89
+ # WRONG — (e => ...) parses as calling e as a function
90
+ @click: (e => e.stopPropagation())
91
+
92
+ # WRONG — (e) => causes parse error in template attribute contexts
93
+ @click: (e) => e.stopPropagation()
94
+
95
+ # WRONG — (e) -> also causes parse error in some template contexts
96
+ @click: (e) -> e.stopPropagation()
97
+
98
+ # CORRECT — define a named method, reference it in the template
99
+ _stopProp: (e) -> e.stopPropagation()
100
+ # then in render:
101
+ .modal @click: @_stopProp
102
+ ```
103
+
104
+ Method references (`@methodName`) always compile correctly in template attributes and receive the event as their first argument.
105
+
106
+ ## ARIA Keyboard and Popup Helpers
107
+
108
+ `ARIA.listNav`, `ARIA.rovingNav`, and `ARIA.popupDismiss` are built into `rip.min.js` (defined in `src/ui.rip`) and available globally in any component without imports.
109
+
110
+ ### ARIA.listNav — popup list keyboard navigation
111
+
112
+ For Select, Menu, Combobox, Autocomplete, and similar popup lists:
113
+
114
+ ```coffee
115
+ onListKeydown: (e) ->
116
+ ARIA.listNav e,
117
+ next: => ... # ArrowDown
118
+ prev: => ... # ArrowUp
119
+ first: => ... # Home, PageUp
120
+ last: => ... # End, PageDown
121
+ select: => ... # Enter, Space
122
+ dismiss: => ... # Escape
123
+ tab: => ... # Tab (no preventDefault — focus moves naturally)
124
+ char: => ... # printable key (typeahead)
125
+ ```
126
+
127
+ ### ARIA.rovingNav — inline composite keyboard navigation
128
+
129
+ For RadioGroup, Tabs, Toolbar, CheckboxGroup, ToggleGroup, Accordion:
130
+
131
+ ```coffee
132
+ onKeydown: (e) ->
133
+ ARIA.rovingNav e, {
134
+ next: => ... # ArrowDown (vertical) / ArrowRight (horizontal) / both
135
+ prev: => ...
136
+ first: => ... # Home, PageUp
137
+ last: => ... # End, PageDown
138
+ select: => ... # Enter, Space (optional)
139
+ }, @orientation # 'vertical' | 'horizontal' | 'both'
140
+ ```
141
+
142
+ ### ARIA.popupDismiss — close on outside click or page scroll
143
+
144
+ For any popup component. Pass lazy getters `(=> @_list)` — NOT the current value `@_list` — because the `~>` effect may run before the render creates the element:
145
+
146
+ ```coffee
147
+ ~> ARIA.popupDismiss open, (=> @_list), (=> @close()), [=> @_trigger]
148
+
149
+ # With scroll repositioning instead of closing (preferred for fixed dropdowns):
150
+ ~> ARIA.popupDismiss open, (=> @_list), (=> @close()), [=> @_trigger], (=> @_position())
151
+ ```
152
+
153
+ **Lazy getters are required**: if you pass `@_list` directly (not as `=> @_list`), the value is captured at the moment the `~>` effect fires — which may be before the render creates the listbox element, capturing `null`. Then `null?.contains(option)` returns `undefined` → `close()` fires on every mousedown, making options unclickable.
154
+
155
+ ### Both nav handlers also:
156
+ - Guard against IME composition (`e.isComposing`) — safe for CJK input methods
157
+ - Call `e.preventDefault()` + `e.stopPropagation()` for handled keys
158
+ - Alias `PageUp/PageDown` to `first/last` (handles macOS `fn+Up/Down`)
159
+
160
+ ## Lifecycle and Component Model
161
+
162
+ - recognized lifecycle hooks: `beforeMount`, `mounted`, `updated`, `beforeUnmount`, `unmounted`, `onError`
163
+ - `onMount` is not a lifecycle hook
164
+ - inside components, `->` is rewritten to `=>`
165
+ - `ref:` sets a plain property, not a reactive signal
166
+ - use the `_ready := false` pattern for effects that need refs after mount
167
+ - `offer` and `accept` only become keywords inside components
168
+ - use `$open`, `$selected`, etc. for data attributes
169
+ - bare `slot` projects `this.children`; it does not create Shadow DOM
170
+ - `@event:` on child components binds listeners to the child root element
171
+ - every component has `emit(name, detail)` which dispatches a bubbling `CustomEvent`
172
+ - `_root` must be set on child components for `emit()` to work
173
+
174
+ ## Integration
175
+
176
+ Add widget bundles through the serve middleware:
177
+
178
+ ```coffee
179
+ use serve
180
+ dir: dir
181
+ bundle:
182
+ ui: ['../../../packages/ui/browser/components']
183
+ app: ['routes', 'components']
184
+ ```
185
+
186
+ Then load with `data-src="ui app"`. Widgets become available by name in the shared scope.
187
+
188
+ ## Grid Highlights
189
+
190
+ - DOM recycling with pooled rows and `textContent` updates
191
+ - Sheets-style selection model
192
+ - full keyboard support
193
+ - TSV clipboard support
194
+ - multi-column sorting
195
+ - column resizing
196
+ - inline editing
197
+
198
+ ## Widget Gallery Dev Server
199
+
200
+ The gallery uses `data-src` mode and a minimal `index.rip` dev server:
201
+
202
+ ```coffee
203
+ import { get, use, start, notFound } from '@rip-lang/server'
204
+ import { serve } from '@rip-lang/server/middleware'
205
+
206
+ dir = import.meta.dir
207
+ use serve dir: dir, bundle: ['components'], watch: true
208
+ get '/*.rip', -> @send "#{dir}/#{@req.path.slice(1)}", 'text/plain; charset=UTF-8'
209
+ notFound -> @send "#{dir}/index.html", 'text/html; charset=UTF-8'
210
+ start port: 3005
211
+ ```
212
+
213
+ Hot reload uses the built-in `/watch` SSE endpoint. Do not implement custom watcher or SSE logic in the worker. Use `notFound`, not `get '/*'`, for the catch-all route or you will intercept serve-middleware assets like `/rip/rip.min.js`.
@@ -0,0 +1,375 @@
1
+ # Contributing to Rip UI
2
+
3
+ Internal guide for contributors working on the widget codebase. For usage
4
+ documentation, see [README.md](README.md).
5
+
6
+ ---
7
+
8
+ ## E2E Overlay QA
9
+
10
+ Run the browser smoke suite for modern overlay primitives:
11
+
12
+ ```bash
13
+ # from repo root
14
+ bun run test:ui:chromium
15
+
16
+ # full browser matrix
17
+ bun run test:ui
18
+
19
+ # optional accessibility scan (Chromium)
20
+ bun run test:ui:axe
21
+ ```
22
+
23
+ If Playwright browsers are not installed yet:
24
+
25
+ ```bash
26
+ bunx playwright install chromium firefox webkit
27
+ ```
28
+
29
+ See [TESTING.md](TESTING.md) for the quality bar and covered scenarios.
30
+
31
+ ---
32
+
33
+ ## Widget Authoring Guide
34
+
35
+ Rules learned building these widgets. Follow them.
36
+
37
+ ### Lifecycle Hooks
38
+
39
+ The recognized hooks are: `beforeMount`, `mounted`, `updated`, `beforeUnmount`,
40
+ `unmounted`, `onError`. That's it. `onMount` is **not** a hook — it compiles as
41
+ a regular method and never gets called. We hit this bug in both Tabs and Toast.
42
+
43
+ ### `->` vs `=>` Inside Components
44
+
45
+ The compiler auto-converts all `->` to `=>` inside component contexts. Use `->` everywhere — it's cleaner and the compiler handles `this` binding.
46
+
47
+ **Caveat:** If you need `this` to refer to a DOM element (e.g., patching
48
+ `HTMLElement.prototype.focus`), put the code OUTSIDE the component body at
49
+ module scope where `->` stays as `->`. Inside a component, `->` becomes `=>`
50
+ and `this` binds to the component, causing "Illegal invocation" on DOM methods.
51
+
52
+ ### `:=` vs `=` for Internal Storage
53
+
54
+ Use `:=` (reactive state) only for values that trigger DOM updates. For internal
55
+ bookkeeping — pools, caches, timer IDs, saved references — use `=` (plain
56
+ assignment). Reactive state creates a signal, tracks dependents, and triggers
57
+ effects on mutation. The Dialog had `_prevFocus := null` and `_cleanupTrap := null`
58
+ as reactive state when they should have been plain variables.
59
+
60
+ ### The `_ready` Flag Pattern
61
+
62
+ Effects run during `_init` (before `_create`), so `ref:` DOM elements don't exist
63
+ yet. Add `_ready := false`, set `_ready = true` in `mounted`, and guard effects
64
+ with `return unless _ready`. The reactive `_ready` flag triggers the effect to
65
+ re-run after mount when DOM refs are available. Used by Tabs, Accordion, and Grid.
66
+
67
+ ### Don't Shadow Prop Names
68
+
69
+ The #1 most dangerous trap. Inside component methods, the compiler rewrites ANY
70
+ identifier matching a prop/state name to `this.name.value`. A local variable
71
+ named `items` will be treated as `this.items.value` if `@items` is a prop —
72
+ meaning `items = getItems()` silently **overwrites your reactive state**.
73
+
74
+ The symptom is usually far from the cause (e.g., a list vanishing on keyboard
75
+ navigation because a helper method corrupted the data source). Always use
76
+ distinct names for locals: `opts` not `items`, `tick` not `step`, `fn` not
77
+ `filter`. When debugging mysterious state corruption, check compiled JS output
78
+ (`rip -c file.rip`) and search for unexpected `this.propName.value =` assignments.
79
+
80
+ ### Type Your Props
81
+
82
+ Adding `::` type annotations to props enables IDE IntelliSense — completions,
83
+ hover info, and diagnostics — for every component that uses yours:
84
+
85
+ ```coffee
86
+ @variant:: 'primary' | 'outline' | 'subtle' := 'primary'
87
+ @disabled:: boolean := false
88
+ @label:: string := ''
89
+ ```
90
+
91
+ Without `::`, the prop is untyped (`any`) and the IDE cannot validate values
92
+ or offer completions. `rip check` treats untyped props as errors.
93
+
94
+ ### `$` Sigil and `data-*` Attributes
95
+
96
+ In render blocks, use the `$` sigil (`$open`, `$selected`) which compiles to
97
+ `data-*` attributes in the HTML output. Consumers style with Tailwind's
98
+ `data-[open]:` and `data-[selected]:` variants, or CSS `[data-open]`,
99
+ `[data-selected]` selectors. The widget never applies visual styles — it only
100
+ sets semantic state attributes. This keeps the headless contract clean.
101
+
102
+ ### `x.y` in Render Blocks Is Tag Syntax
103
+
104
+ Inside render blocks, `item.textContent` on its own line is parsed as tag `item`
105
+ with CSS class `textContent` — not a property access. Use the `=` prefix to
106
+ output expressions as text: `= item.textContent`.
107
+
108
+ ### Widget Conventions
109
+
110
+ - `ref: "_name"` for DOM references — never `div._name` (dot syntax sets CSS class)
111
+ - `_trigger` for trigger elements, `_list` for dropdown lists, `_content` for content areas
112
+ - `_slot` with `style: "display:none"` for hidden slot reading (Select, Menu)
113
+ - `=!` for constant values (IDs), `:=` only for reactive state that drives DOM
114
+ - Auto-wired events: methods named `onClick`, `onKeydown`, etc. bind to root automatically
115
+ - `@emit 'eventName', detail` dispatches a CustomEvent on the component's root element
116
+ - Shared-scope naming: prefix module-scope variables with widget name (`acCollator` not `collator`)
117
+
118
+ ### Imperative DOM for Performance
119
+
120
+ For 60fps paths (Grid scroll, ScrollArea thumb), bypass reactive rendering and do
121
+ imperative DOM inside `~>` effects. Read DOM, compute, write DOM in one pass.
122
+
123
+ Rule: if the data source is a DOM property (`scrollTop`, `clientHeight`,
124
+ `getBoundingClientRect`), go imperative. If it's reactive state (`:=`, `~=`),
125
+ use the reactive system. The Grid, ScrollArea, and any future drag/resize widget
126
+ should follow this pattern.
127
+
128
+ ### Side Effects in Effect Branches
129
+
130
+ When a prop like `@open` is controlled via `<=>`, the consumer can set it
131
+ directly (`showDrawer = false`) without calling `close()`. If scroll lock, focus
132
+ restore, or cleanup only lives in `close()`, it won't run. Use
133
+ `~> if @open ... else ...` so the effect handles all state transitions regardless
134
+ of how the signal changed. Methods like `close()` should just set state and emit
135
+ events — the effect does the work.
136
+
137
+ ### Explicit Index Names in Nested Loops
138
+
139
+ When a `for` loop in a render block has no explicit index, the compiler
140
+ auto-generates `i`. Nested loops both get `i`, producing duplicate parameters.
141
+ Fix: always name both indices explicitly (`for outer, oIdx in list` /
142
+ `for inner, iIdx in sublist`). Single loops are fine without an explicit index.
143
+
144
+ ### Don't Use `value: @prop` on `<input>`
145
+
146
+ Rip's smart auto-binding writes the input's string value back to the signal,
147
+ corrupting numeric state. Use a `_ready`-guarded `~>` effect to push values to
148
+ the input, and `@blur`/`@input` handlers to parse back.
149
+
150
+ ---
151
+
152
+ ## Behavioral Primitives
153
+
154
+ The widgets are built from shared behavioral patterns:
155
+
156
+ **Focus Trap** — confines tab focus within a container (dialogs, modals):
157
+
158
+ ```coffee
159
+ trapFocus = (el) ->
160
+ focusable = el.querySelectorAll 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])'
161
+ first = focusable[0]
162
+ last = focusable[focusable.length - 1]
163
+ first?.focus()
164
+ handler = (e) ->
165
+ return unless e.key is 'Tab'
166
+ if e.shiftKey
167
+ if document.activeElement is first then e.preventDefault(); last?.focus()
168
+ else
169
+ if document.activeElement is last then e.preventDefault(); first?.focus()
170
+ el.addEventListener 'keydown', handler
171
+ -> el.removeEventListener 'keydown', handler
172
+ ```
173
+
174
+ **Scroll Lock** — prevents body scroll while a modal is open:
175
+
176
+ ```coffee
177
+ lockScroll = ->
178
+ scrollY = window.scrollY
179
+ document.body.style.position = 'fixed'
180
+ document.body.style.top = "-#{scrollY}px"
181
+ document.body.style.width = '100%'
182
+ ->
183
+ document.body.style.position = ''
184
+ document.body.style.top = ''
185
+ document.body.style.width = ''
186
+ window.scrollTo 0, scrollY
187
+ ```
188
+
189
+ **Dismiss** — close on Escape key or click outside:
190
+
191
+ ```coffee
192
+ onDismiss = (el, close) ->
193
+ onKey = (e) -> close() if e.key is 'Escape'
194
+ onClick = (e) -> close() unless el.contains(e.target)
195
+ document.addEventListener 'keydown', onKey
196
+ document.addEventListener 'pointerdown', onClick
197
+ ->
198
+ document.removeEventListener 'keydown', onKey
199
+ document.removeEventListener 'pointerdown', onClick
200
+ ```
201
+
202
+ **Keyboard Navigation** — arrow key movement through a list:
203
+
204
+ ```coffee
205
+ navigateList = (el, opts = {}) ->
206
+ vertical = opts.vertical ? true
207
+ wrap = opts.wrap ? true
208
+ items = -> el.querySelectorAll('[role="option"]:not([aria-disabled="true"])')
209
+ handler = (e) ->
210
+ list = Array.from items()
211
+ idx = list.indexOf document.activeElement
212
+ return if idx is -1
213
+ next = switch e.key
214
+ when (if vertical then 'ArrowDown' else 'ArrowRight')
215
+ if wrap then (idx + 1) %% list.length else Math.min(idx + 1, list.length - 1)
216
+ when (if vertical then 'ArrowUp' else 'ArrowLeft')
217
+ if wrap then (idx - 1) %% list.length else Math.max(idx - 1, 0)
218
+ when 'Home' then 0
219
+ when 'End' then list.length - 1
220
+ else null
221
+ if next? then e.preventDefault(); list[next].focus()
222
+ el.addEventListener 'keydown', handler
223
+ -> el.removeEventListener 'keydown', handler
224
+ ```
225
+
226
+ **Anchor Positioning** — position a floating element relative to a trigger:
227
+
228
+ ```coffee
229
+ anchorPosition = (anchor, floating, opts = {}) ->
230
+ placement = opts.placement or 'bottom'
231
+ offset = opts.offset or 4
232
+ update = ->
233
+ ar = anchor.getBoundingClientRect()
234
+ fr = floating.getBoundingClientRect()
235
+ [side, align] = placement.split('-')
236
+ x = switch side
237
+ when 'bottom', 'top'
238
+ switch align
239
+ when 'start' then ar.left
240
+ when 'end' then ar.right - fr.width
241
+ else ar.left + (ar.width - fr.width) / 2
242
+ when 'right' then ar.right + offset
243
+ when 'left' then ar.left - fr.width - offset
244
+ y = switch side
245
+ when 'bottom' then ar.bottom + offset
246
+ when 'top' then ar.top - fr.height - offset
247
+ when 'left', 'right'
248
+ switch align
249
+ when 'start' then ar.top
250
+ when 'end' then ar.bottom - fr.height
251
+ else ar.top + (ar.height - fr.height) / 2
252
+ # Flip if off screen, shift to stay in viewport
253
+ x = Math.max(4, Math.min(x, window.innerWidth - fr.width - 4))
254
+ floating.style.left = "#{x}px"
255
+ floating.style.top = "#{y}px"
256
+ update()
257
+ ```
258
+
259
+ **Reference Material:**
260
+ - [WAI-ARIA Authoring Practices](https://www.w3.org/WAI/ARIA/apg/patterns/)
261
+ - [Base UI source (MIT)](https://github.com/mui/base-ui)
262
+ - [MDN ARIA documentation](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA)
263
+
264
+ ---
265
+
266
+ ## Per-Widget Implementation Notes
267
+
268
+ ### Select
269
+ - Typeahead buffer clears after 500ms (matches native `<select>` behavior)
270
+ - Hidden slot pattern for declarative option reading
271
+ - Positioning: manual `getBoundingClientRect` with basic flip (up when overflowing)
272
+
273
+ ### Combobox
274
+ - Consumer controls filtering via `@filter` callback (no internal filtering, no debounce)
275
+ - Highlighted index resets to -1 on each input change
276
+
277
+ ### Dialog
278
+ - Focus trap set up in `setTimeout` (after dialog renders)
279
+ - Internal storage (`_prevFocus`, `_cleanupTrap`) uses plain `=` not `:=`
280
+ - Enter/exit animations handled via CSS on `[data-open]`
281
+
282
+ ### Toast
283
+ - Each Toast is independent (no stacking/queue system)
284
+ - 200ms leave animation duration is hardcoded
285
+
286
+ ### Popover
287
+ - Uses `[data-trigger]` and `[data-content]` children for structure
288
+
289
+ ### Tooltip
290
+ - Show delay 300ms, instant hide + 150ms animation
291
+
292
+ ### Tabs
293
+ - Content discovered by querying `[data-tab]`/`[data-panel]` inside component
294
+ - Deeply nested tabs must be direct-ish children
295
+
296
+ ### Grid
297
+ - Hybrid: reactive rendering for structure, imperative DOM for scroll hot path
298
+ - DOM recycling pool (`_trPool`) never shrinks — avoids create/destroy cycles on resize
299
+ - Clipboard: TSV format per RFC 4180
300
+ - `requestAnimationFrame` throttle coalesces scroll events
301
+ - `contain: strict` and `will-change: transform` recommended as user styles
302
+
303
+ ### Accordion
304
+ - ARIA attributes: `aria-expanded`, `aria-controls`, `role="region"`
305
+ - `openItems` Set replaced with new Set on each toggle to trigger reactivity
306
+
307
+ ### Menu
308
+ - Hidden-slot pattern (same as Select) for item discovery
309
+ - Disabled items skipped on click but not keyboard navigation
310
+
311
+ ---
312
+
313
+ ## Known Structural Issues
314
+
315
+ **Grid — hardcoded selection color.** `#3b82f6` in the selection overlay should
316
+ be `var(--grid-selection-color, #3b82f6)`.
317
+
318
+ ---
319
+
320
+ ## Cross-Widget Notes
321
+
322
+ ### Positioning
323
+ Select, Combobox, Menu, and Popover each do their own `getBoundingClientRect`
324
+ math. If this becomes a maintenance issue, extract a shared function. For now,
325
+ inlined code is simple enough that duplication is preferable to indirection.
326
+
327
+ ### Slot Discovery
328
+ Tabs, Accordion, Select, and Combobox discover children by querying `data-*`
329
+ attributes.
330
+
331
+ ### CSS Hot Reload
332
+ Save a `.css` file and the browser picks up changes without losing component
333
+ state (SSE `data: styles` event refreshes stylesheets only). `.rip` changes
334
+ trigger a full page reload.
335
+
336
+ ### Testing
337
+ No widget has tests yet. Priority:
338
+ 1. Dialog — focus trap correctness
339
+ 2. Select — keyboard + typeahead
340
+ 3. Grid — virtual scroll, DOM recycling, clipboard TSV round-trip
341
+
342
+ ---
343
+
344
+ ## Roadmap
345
+
346
+ 1. **Write Grid tests** — viewport engine, DOM recycling, clipboard, sort
347
+ 2. **Write Dialog tests** — focus trap, scroll lock, escape, click-outside, focus restore
348
+ 3. **Write Select tests** — keyboard nav, typeahead, Home/End, ARIA correctness
349
+ 4. **Grid: frozen columns** — `position: sticky` with cumulative left offset
350
+ 5. **Grid: selection color** — `var(--grid-selection-color, #3b82f6)`
351
+ 6. **Standalone Grid demo** — 100K rows, prove 60fps
352
+ 7. **Grid: CellRange model** — unlocks multi-range selection (Ctrl+click)
353
+ 8. **Grid: undo/redo** — ~40 lines on top of existing `commitEditor`
354
+ 9. **Publish** — document integration, link from main README
355
+
356
+ ---
357
+
358
+ ## Dev Server
359
+
360
+ The widget gallery uses `data-src` mode for testing. The dev server is minimal:
361
+
362
+ ```coffee
363
+ import { get, use, start, notFound } from '@rip-lang/server'
364
+ import { serve } from '@rip-lang/server/middleware'
365
+
366
+ dir = import.meta.dir
367
+ use serve dir: dir, bundle: ['.'], watch: true
368
+ get '/*.rip', -> @send "#{dir}/#{@req.path.slice(1)}", 'text/plain; charset=UTF-8'
369
+ notFound -> @send "#{dir}/index.html", 'text/html; charset=UTF-8'
370
+ start port: 3005
371
+ ```
372
+
373
+ `rip server` from `packages/ui/` gives auto-HTTPS + mDNS. Do NOT implement
374
+ custom file watchers or SSE endpoints — the process manager handles that.
375
+ Use `notFound` (not `get '/*'`) for the catch-all route.
@@ -0,0 +1,11 @@
1
+ # @rip-lang/ui/browser
2
+
3
+ Headless, accessible browser widgets for Rip.
4
+
5
+ The components are source-first `.rip` files intended to be bundled and compiled in the browser. They expose semantic state through `$` / `data-*` attributes and ship no visual CSS.
6
+
7
+ ```coffee
8
+ use serve
9
+ dir: dir
10
+ bundle: ['browser/components']
11
+ ```
@@ -0,0 +1,59 @@
1
+ # UI Quality Bar
2
+
3
+ This package now has an end-to-end browser harness for modern overlay primitives
4
+ (`popover`, `<dialog>`, anchor positioning behavior) and keyboard/accessibility
5
+ smoke checks.
6
+
7
+ ## Goals
8
+
9
+ - Catch regressions in overlay open/close semantics early.
10
+ - Keep behavior consistent across Chromium, Firefox, and WebKit.
11
+ - Verify baseline ARIA/role wiring for critical widgets.
12
+
13
+ ## Run
14
+
15
+ From repo root:
16
+
17
+ ```bash
18
+ bun run test:ui:chromium
19
+ bun run test:ui
20
+ bun run test:ui:axe
21
+ ```
22
+
23
+ From `packages/ui`:
24
+
25
+ ```bash
26
+ bun run test:e2e:chromium
27
+ bun run test:e2e
28
+ bun run test:e2e:headed
29
+ bun run test:e2e:axe
30
+ ```
31
+
32
+ ## Browser Setup
33
+
34
+ Playwright binaries are not pinned as repo dependencies. Install browsers on
35
+ your machine as needed:
36
+
37
+ ```bash
38
+ bunx playwright install chromium firefox webkit
39
+ ```
40
+
41
+ ## Current Coverage
42
+
43
+ `tests/e2e/overlay-primitives.spec.js` covers:
44
+
45
+ - Popover: open/escape/outside-dismiss
46
+ - Dialog: open + escape close
47
+ - AlertDialog: escape blocked until explicit action
48
+ - Menu: role/open-state semantics
49
+ - Select: keyboard open/selection close
50
+ - Tooltip: hover + role visibility
51
+ - Nested overlay: popover interaction while dialog is open
52
+ - Race smoke: repeated popover open/escape cycles
53
+ - Optional axe scan (`UI_AXE=1`): blocks on critical issues; reports serious issues
54
+
55
+ ## Next Tightening Steps
56
+
57
+ - Add focus-return assertions for all modal/popup components.
58
+ - Add stress tests for rapid toggle/open-close races.
59
+ - Expand axe coverage to include menubar/nav-menu and context-menu sections.