@rip-lang/ui 0.3.67 → 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.
- package/AGENTS.md +93 -0
- package/README.md +22 -625
- package/browser/AGENTS.md +213 -0
- package/browser/CONTRIBUTING.md +375 -0
- package/browser/README.md +11 -0
- package/browser/TESTING.md +59 -0
- package/browser/browser.rip +56 -0
- package/{components → browser/components}/accordion.rip +1 -1
- package/{components → browser/components}/alert-dialog.rip +6 -3
- package/{components → browser/components}/autocomplete.rip +27 -21
- package/{components → browser/components}/avatar.rip +3 -3
- package/{components → browser/components}/badge.rip +1 -1
- package/{components → browser/components}/breadcrumb.rip +2 -2
- package/{components → browser/components}/button-group.rip +3 -3
- package/{components → browser/components}/button.rip +2 -2
- package/{components → browser/components}/card.rip +1 -1
- package/{components → browser/components}/carousel.rip +5 -5
- package/{components → browser/components}/checkbox-group.rip +40 -11
- package/{components → browser/components}/checkbox.rip +4 -4
- package/{components → browser/components}/collapsible.rip +2 -2
- package/{components → browser/components}/combobox.rip +36 -23
- package/{components → browser/components}/context-menu.rip +1 -1
- package/{components → browser/components}/date-picker.rip +5 -5
- package/{components → browser/components}/dialog.rip +8 -4
- package/{components → browser/components}/drawer.rip +8 -4
- package/{components → browser/components}/editable-value.rip +7 -1
- package/{components → browser/components}/field.rip +5 -5
- package/{components → browser/components}/fieldset.rip +2 -2
- package/{components → browser/components}/form.rip +1 -1
- package/{components → browser/components}/grid.rip +8 -8
- package/{components → browser/components}/input-group.rip +1 -1
- package/{components → browser/components}/input.rip +6 -6
- package/{components → browser/components}/label.rip +2 -2
- package/{components → browser/components}/menu.rip +17 -10
- package/{components → browser/components}/menubar.rip +1 -1
- package/{components → browser/components}/meter.rip +7 -7
- package/{components → browser/components}/multi-select.rip +76 -33
- package/{components → browser/components}/native-select.rip +3 -3
- package/{components → browser/components}/nav-menu.rip +3 -3
- package/{components → browser/components}/number-field.rip +11 -11
- package/{components → browser/components}/otp-field.rip +4 -4
- package/{components → browser/components}/pagination.rip +4 -4
- package/{components → browser/components}/popover.rip +11 -24
- package/{components → browser/components}/preview-card.rip +7 -11
- package/{components → browser/components}/progress.rip +3 -3
- package/{components → browser/components}/radio-group.rip +4 -4
- package/{components → browser/components}/resizable.rip +3 -3
- package/{components → browser/components}/scroll-area.rip +1 -1
- package/{components → browser/components}/select.rip +55 -27
- package/{components → browser/components}/separator.rip +2 -2
- package/{components → browser/components}/skeleton.rip +4 -4
- package/{components → browser/components}/slider.rip +15 -10
- package/{components → browser/components}/spinner.rip +2 -2
- package/{components → browser/components}/table.rip +2 -2
- package/{components → browser/components}/tabs.rip +12 -7
- package/{components → browser/components}/textarea.rip +8 -8
- package/{components → browser/components}/toast.rip +3 -3
- package/{components → browser/components}/toggle-group.rip +42 -11
- package/{components → browser/components}/toggle.rip +2 -2
- package/{components → browser/components}/toolbar.rip +2 -2
- package/{components → browser/components}/tooltip.rip +19 -23
- package/browser/hljs-rip.js +209 -0
- package/browser/playwright.config.mjs +31 -0
- package/browser/tests/overlays.js +349 -0
- package/email/AGENTS.md +16 -0
- package/email/README.md +55 -0
- package/email/benchmarks/benchmark.rip +94 -0
- package/email/benchmarks/samples.rip +104 -0
- package/email/compat.rip +129 -0
- package/email/components.rip +371 -0
- package/email/dom.rip +330 -0
- package/email/email.rip +10 -0
- package/email/render.rip +82 -0
- package/package.json +29 -39
- package/shared/README.md +3 -0
- package/shared/styles.rip +17 -0
- package/tailwind/AGENTS.md +3 -0
- package/tailwind/README.md +27 -0
- package/tailwind/engine.js +107 -0
- package/tailwind/inline.js +215 -0
- package/tailwind/serve.js +6 -0
- package/tailwind/tailwind.rip +13 -0
- 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.
|