@remix-run/ui 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (243) hide show
  1. package/README.md +33 -10
  2. package/dist/animation/animate-layout-mixin.js +66 -66
  3. package/dist/animation/animate-layout-mixin.js.map +1 -1
  4. package/dist/animation/spring.d.ts +6 -35
  5. package/dist/animation/spring.js +9 -8
  6. package/dist/animation/spring.js.map +1 -1
  7. package/dist/components/accordion/accordion.d.ts +7 -7
  8. package/dist/components/accordion/accordion.js +0 -2
  9. package/dist/components/accordion/accordion.js.map +1 -1
  10. package/dist/components/accordion/demos/card.demo.d.ts +6 -0
  11. package/dist/components/accordion/demos/card.demo.js +58 -0
  12. package/dist/components/accordion/demos/card.demo.js.map +1 -0
  13. package/dist/components/accordion/demos/multiple.demo.d.ts +6 -0
  14. package/dist/components/accordion/demos/multiple.demo.js +19 -0
  15. package/dist/components/accordion/demos/multiple.demo.js.map +1 -0
  16. package/dist/components/accordion/demos/overview.demo.d.ts +6 -0
  17. package/dist/components/accordion/demos/overview.demo.js +19 -0
  18. package/dist/components/accordion/demos/overview.demo.js.map +1 -0
  19. package/dist/components/breadcrumbs/breadcrumbs.d.ts +1 -1
  20. package/dist/components/breadcrumbs/breadcrumbs.js +0 -2
  21. package/dist/components/breadcrumbs/breadcrumbs.js.map +1 -1
  22. package/dist/components/breadcrumbs/demos/basic.demo.d.ts +5 -0
  23. package/dist/components/breadcrumbs/demos/basic.demo.js +14 -0
  24. package/dist/components/breadcrumbs/demos/basic.demo.js.map +1 -0
  25. package/dist/components/breadcrumbs/demos/separator.demo.d.ts +5 -0
  26. package/dist/components/breadcrumbs/demos/separator.demo.js +14 -0
  27. package/dist/components/breadcrumbs/demos/separator.demo.js.map +1 -0
  28. package/dist/components/button/button.d.ts +86 -4
  29. package/dist/components/button/button.js +63 -3
  30. package/dist/components/button/button.js.map +1 -1
  31. package/dist/components/button/demos/aliases.demo.d.ts +6 -0
  32. package/dist/components/button/demos/aliases.demo.js +18 -0
  33. package/dist/components/button/demos/aliases.demo.js.map +1 -0
  34. package/dist/components/button/demos/basic.demo.d.ts +6 -0
  35. package/dist/components/button/demos/basic.demo.js +19 -0
  36. package/dist/components/button/demos/basic.demo.js.map +1 -0
  37. package/dist/components/button/demos/states.demo.d.ts +6 -0
  38. package/dist/components/button/demos/states.demo.js +29 -0
  39. package/dist/components/button/demos/states.demo.js.map +1 -0
  40. package/dist/components/combobox/combobox.d.ts +11 -11
  41. package/dist/components/combobox/combobox.js +0 -2
  42. package/dist/components/combobox/combobox.js.map +1 -1
  43. package/dist/components/combobox/demos/overview.demo.d.ts +6 -0
  44. package/dist/components/combobox/demos/overview.demo.js +153 -0
  45. package/dist/components/combobox/demos/overview.demo.js.map +1 -0
  46. package/dist/components/listbox/demos/overview.demo.d.ts +6 -0
  47. package/dist/components/listbox/demos/overview.demo.js +64 -0
  48. package/dist/components/listbox/demos/overview.demo.js.map +1 -0
  49. package/dist/components/listbox/listbox.d.ts +4 -9
  50. package/dist/components/listbox/listbox.js +21 -33
  51. package/dist/components/listbox/listbox.js.map +1 -1
  52. package/dist/components/menu/demos/bubbling.demo.d.ts +5 -0
  53. package/dist/components/menu/demos/bubbling.demo.js +15 -0
  54. package/dist/components/menu/demos/bubbling.demo.js.map +1 -0
  55. package/dist/components/menu/demos/overview.demo.d.ts +6 -0
  56. package/dist/components/menu/demos/overview.demo.js +32 -0
  57. package/dist/components/menu/demos/overview.demo.js.map +1 -0
  58. package/dist/components/menu/menu.d.ts +15 -24
  59. package/dist/components/menu/menu.js +23 -38
  60. package/dist/components/menu/menu.js.map +1 -1
  61. package/dist/components/popover/demos/overview.demo.d.ts +6 -0
  62. package/dist/components/popover/demos/overview.demo.js +64 -0
  63. package/dist/components/popover/demos/overview.demo.js.map +1 -0
  64. package/dist/components/popover/popover.d.ts +5 -5
  65. package/dist/components/popover/popover.js.map +1 -1
  66. package/dist/components/select/demos/deconstructed.demo.d.ts +6 -0
  67. package/dist/components/select/demos/deconstructed.demo.js +53 -0
  68. package/dist/components/select/demos/deconstructed.demo.js.map +1 -0
  69. package/dist/components/select/demos/overview.demo.d.ts +5 -0
  70. package/dist/components/select/demos/overview.demo.js +27 -0
  71. package/dist/components/select/demos/overview.demo.js.map +1 -0
  72. package/dist/components/select/select.d.ts +11 -11
  73. package/dist/components/select/select.js +17 -26
  74. package/dist/components/select/select.js.map +1 -1
  75. package/dist/components/tabs/tabs.d.ts +6 -6
  76. package/dist/components/tabs/tabs.js +0 -2
  77. package/dist/components/tabs/tabs.js.map +1 -1
  78. package/dist/index.d.ts +2 -2
  79. package/dist/index.js.map +1 -1
  80. package/dist/interactions/keydown/keydown.d.ts +1 -1
  81. package/dist/interactions/outside-click/outside-click-mixin.d.ts +2 -1
  82. package/dist/interactions/typeahead/typeahead-mixin.d.ts +3 -1
  83. package/dist/runtime/component.d.ts +18 -12
  84. package/dist/runtime/component.js +93 -80
  85. package/dist/runtime/component.js.map +1 -1
  86. package/dist/runtime/core/attributes.d.ts +14 -0
  87. package/dist/runtime/core/attributes.js +103 -0
  88. package/dist/runtime/core/attributes.js.map +1 -0
  89. package/dist/runtime/core/children.d.ts +7 -0
  90. package/dist/runtime/core/children.js +30 -0
  91. package/dist/runtime/core/children.js.map +1 -0
  92. package/dist/runtime/core/props.d.ts +9 -0
  93. package/dist/runtime/core/props.js +170 -0
  94. package/dist/runtime/core/props.js.map +1 -0
  95. package/dist/runtime/core/vnode.d.ts +3 -0
  96. package/dist/runtime/core/vnode.js +39 -0
  97. package/dist/runtime/core/vnode.js.map +1 -0
  98. package/dist/runtime/create-element.js +2 -1
  99. package/dist/runtime/create-element.js.map +1 -1
  100. package/dist/runtime/diff-dom.js +47 -14
  101. package/dist/runtime/diff-dom.js.map +1 -1
  102. package/dist/runtime/diff-props.d.ts +1 -2
  103. package/dist/runtime/diff-props.js +2 -181
  104. package/dist/runtime/diff-props.js.map +1 -1
  105. package/dist/runtime/dom.d.ts +20 -9
  106. package/dist/runtime/frame.d.ts +23 -2
  107. package/dist/runtime/frame.js +41 -47
  108. package/dist/runtime/frame.js.map +1 -1
  109. package/dist/runtime/jsx.d.ts +1 -1
  110. package/dist/runtime/jsx.js +2 -28
  111. package/dist/runtime/jsx.js.map +1 -1
  112. package/dist/runtime/mixins/link-mixin.d.ts +5 -1
  113. package/dist/runtime/mixins/link-mixin.js.map +1 -1
  114. package/dist/runtime/mixins/mixin.d.ts +2 -1
  115. package/dist/runtime/mixins/mixin.js +9 -4
  116. package/dist/runtime/mixins/mixin.js.map +1 -1
  117. package/dist/runtime/mixins/on-mixin.d.ts +15 -4
  118. package/dist/runtime/mixins/on-mixin.js +9 -2
  119. package/dist/runtime/mixins/on-mixin.js.map +1 -1
  120. package/dist/runtime/mixins/ref-mixin.d.ts +2 -1
  121. package/dist/runtime/mixins/ref-mixin.js.map +1 -1
  122. package/dist/runtime/navigation.js +9 -1
  123. package/dist/runtime/navigation.js.map +1 -1
  124. package/dist/runtime/reconcile.d.ts +1 -1
  125. package/dist/runtime/reconcile.js +452 -197
  126. package/dist/runtime/reconcile.js.map +1 -1
  127. package/dist/runtime/run.d.ts +17 -2
  128. package/dist/runtime/run.js.map +1 -1
  129. package/dist/runtime/scheduler.d.ts +10 -10
  130. package/dist/runtime/scheduler.js +1 -1
  131. package/dist/runtime/scheduler.js.map +1 -1
  132. package/dist/runtime/stream-protocol.d.ts +8 -0
  133. package/dist/runtime/stream-protocol.js +20 -0
  134. package/dist/runtime/stream-protocol.js.map +1 -0
  135. package/dist/runtime/svg-attributes.js +1 -0
  136. package/dist/runtime/svg-attributes.js.map +1 -1
  137. package/dist/runtime/to-vnode.js +7 -11
  138. package/dist/runtime/to-vnode.js.map +1 -1
  139. package/dist/runtime/vnode.d.ts +11 -5
  140. package/dist/runtime/vnode.js +6 -3
  141. package/dist/runtime/vnode.js.map +1 -1
  142. package/dist/server/stream.d.ts +2 -0
  143. package/dist/server/stream.js +154 -114
  144. package/dist/server/stream.js.map +1 -1
  145. package/dist/style/css-mixin.d.ts +2 -1
  146. package/dist/style/css-mixin.js.map +1 -1
  147. package/dist/style/index.d.ts +1 -1
  148. package/dist/style/layers.d.ts +2 -0
  149. package/dist/style/layers.js +3 -0
  150. package/dist/style/layers.js.map +1 -0
  151. package/dist/style/stylesheet.d.ts +13 -11
  152. package/dist/style/stylesheet.js +69 -8
  153. package/dist/style/stylesheet.js.map +1 -1
  154. package/dist/theme/contract.d.ts +24 -222
  155. package/dist/theme/contract.js +2 -2
  156. package/dist/theme/contract.js.map +1 -1
  157. package/dist/theme/glyph-contract.d.ts +1 -3
  158. package/dist/theme/glyph-contract.js.map +1 -1
  159. package/dist/theme/presets/rmx-01/index.d.ts +4 -2
  160. package/dist/theme/presets/rmx-01/index.js.map +1 -1
  161. package/dist/theme/runtime.js +4 -1
  162. package/dist/theme/runtime.js.map +1 -1
  163. package/dist/utils/scroll-lock.d.ts +3 -3
  164. package/dist/utils/scroll-lock.js.map +1 -1
  165. package/package.json +3 -3
  166. package/src/animation/README.md +316 -0
  167. package/src/animation/animate-layout-mixin.ts +79 -71
  168. package/src/animation/spring.ts +21 -9
  169. package/src/components/accordion/README.md +166 -0
  170. package/src/components/accordion/accordion.tsx +14 -14
  171. package/src/components/accordion/demos/card.demo.tsx +113 -0
  172. package/src/components/accordion/demos/multiple.demo.tsx +56 -0
  173. package/src/components/accordion/demos/overview.demo.tsx +56 -0
  174. package/src/components/anchor/README.md +153 -0
  175. package/src/components/anchor/anchor.ts +1 -1
  176. package/src/components/breadcrumbs/README.md +55 -0
  177. package/src/components/breadcrumbs/breadcrumbs.tsx +1 -3
  178. package/src/components/breadcrumbs/demos/basic.demo.tsx +17 -0
  179. package/src/components/breadcrumbs/demos/separator.demo.tsx +18 -0
  180. package/src/components/button/README.md +4 -4
  181. package/src/components/button/button.tsx +118 -20
  182. package/src/components/button/demos/aliases.demo.tsx +27 -0
  183. package/src/components/button/demos/basic.demo.tsx +30 -0
  184. package/src/components/button/demos/states.demo.tsx +52 -0
  185. package/src/components/combobox/README.md +1 -1
  186. package/src/components/combobox/combobox.tsx +37 -21
  187. package/src/components/combobox/demos/overview.demo.tsx +199 -0
  188. package/src/components/glyph/README.md +72 -0
  189. package/src/components/glyph/glyph.tsx +2 -2
  190. package/src/components/listbox/README.md +115 -0
  191. package/src/components/listbox/demos/overview.demo.tsx +101 -0
  192. package/src/components/listbox/listbox.ts +106 -110
  193. package/src/components/menu/README.md +96 -0
  194. package/src/components/menu/demos/bubbling.demo.tsx +34 -0
  195. package/src/components/menu/demos/overview.demo.tsx +76 -0
  196. package/src/components/menu/menu.tsx +61 -55
  197. package/src/components/popover/README.md +3 -3
  198. package/src/components/popover/demos/overview.demo.tsx +143 -0
  199. package/src/components/popover/popover.ts +90 -79
  200. package/src/components/select/README.md +107 -0
  201. package/src/components/select/demos/deconstructed.demo.tsx +95 -0
  202. package/src/components/select/demos/overview.demo.tsx +51 -0
  203. package/src/components/select/select.tsx +53 -36
  204. package/src/components/tabs/tabs.tsx +10 -5
  205. package/src/index.ts +2 -1
  206. package/src/runtime/component.ts +122 -88
  207. package/src/runtime/core/attributes.ts +118 -0
  208. package/src/runtime/core/children.ts +42 -0
  209. package/src/runtime/core/props.ts +187 -0
  210. package/src/runtime/core/vnode.ts +48 -0
  211. package/src/runtime/create-element.ts +2 -1
  212. package/src/runtime/diff-dom.ts +59 -12
  213. package/src/runtime/diff-props.ts +2 -187
  214. package/src/runtime/dom.ts +20 -9
  215. package/src/runtime/frame.ts +71 -55
  216. package/src/runtime/jsx.ts +3 -30
  217. package/src/runtime/mixins/link-mixin.ts +88 -86
  218. package/src/runtime/mixins/mixin.ts +18 -5
  219. package/src/runtime/mixins/on-mixin.ts +20 -12
  220. package/src/runtime/mixins/ref-mixin.ts +4 -5
  221. package/src/runtime/navigation.ts +10 -1
  222. package/src/runtime/reconcile.ts +563 -223
  223. package/src/runtime/run.ts +17 -1
  224. package/src/runtime/scheduler.ts +20 -20
  225. package/src/runtime/stream-protocol.ts +27 -0
  226. package/src/runtime/svg-attributes.ts +1 -0
  227. package/src/runtime/to-vnode.ts +7 -10
  228. package/src/runtime/vnode.ts +16 -9
  229. package/src/server/README.md +90 -0
  230. package/src/server/stream.ts +176 -124
  231. package/src/style/css-mixin.ts +6 -1
  232. package/src/style/index.ts +1 -2
  233. package/src/style/layers.ts +2 -0
  234. package/src/style/stylesheet.ts +88 -11
  235. package/src/test/README.md +107 -0
  236. package/src/test/utils.ts +3 -1
  237. package/src/theme/README.md +103 -0
  238. package/src/theme/contract.ts +37 -5
  239. package/src/theme/glyph-contract.ts +3 -1
  240. package/src/theme/presets/rmx-01/index.ts +4 -2
  241. package/src/theme/runtime.ts +4 -1
  242. package/src/utils/scroll-lock/README.md +33 -0
  243. package/src/utils/scroll-lock.ts +7 -3
@@ -1,10 +1,23 @@
1
+ import { REMIX_UI_STYLE_LAYER } from './layers.ts'
2
+
1
3
  type RuleEntry = { count: number; index: number }
2
- type ServerStyleSource = ParentNode | Iterable<Node>
4
+ export type ServerStyleSource = ParentNode | Iterable<Node>
5
+
6
+ export interface StyleManager {
7
+ insert(className: string, rule: string): void
8
+ remove(className: string): void
9
+ has(className: string): boolean
10
+ getGeneration(): number
11
+ reset(): void
12
+ adoptServerStyles(source: ServerStyleSource): Set<string>
13
+ replaceServerStyles(source: ServerStyleSource): void
14
+ selectors(): IterableIterator<string>
15
+ dispose(): void
16
+ }
3
17
 
4
18
  const SERVER_STYLE_SELECTOR = 'style[data-rmx]'
5
- const DEFAULT_STYLE_LAYER = 'rmx'
6
19
 
7
- function getStyleLayerName(className: string, layer: string = DEFAULT_STYLE_LAYER): string {
20
+ function getStyleLayerName(className: string, layer: string = REMIX_UI_STYLE_LAYER): string {
8
21
  return `${layer}.${className}`
9
22
  }
10
23
 
@@ -67,7 +80,7 @@ function getStyleSelector(styleEl: HTMLStyleElement): string | null {
67
80
  return selector ? selector : null
68
81
  }
69
82
 
70
- export function createStyleManager(layer: string = 'rmx') {
83
+ export function createStyleManager(layer: string = REMIX_UI_STYLE_LAYER): StyleManager {
71
84
  let stylesheet: CSSStyleSheet | null = null
72
85
  let generation = 0
73
86
 
@@ -75,6 +88,13 @@ export function createStyleManager(layer: string = 'rmx') {
75
88
  // Using an object to track both count and index together
76
89
  let ruleMap = new Map<string, RuleEntry>()
77
90
 
91
+ // Selectors currently held by a server-style adoption ref. We track this
92
+ // separately from `ruleMap` so `replaceServerStyles` can release only the
93
+ // adoption refs of selectors absent from the next page, without disturbing
94
+ // selectors that exist solely because a client-side css mixin inserted them
95
+ // (e.g. transient UI state that the server never rendered).
96
+ let adoptedSelectors = new Set<string>()
97
+
78
98
  function getStylesheet(): CSSStyleSheet {
79
99
  if (!stylesheet) {
80
100
  stylesheet = new CSSStyleSheet()
@@ -98,19 +118,27 @@ export function createStyleManager(layer: string = 'rmx') {
98
118
  }
99
119
  }
100
120
 
101
- function adoptServerStyleTag(styleEl: HTMLStyleElement) {
121
+ function adoptServerStyleTag(styleEl: HTMLStyleElement): string | undefined {
102
122
  let selector = getStyleSelector(styleEl)
103
- if (!selector) return
123
+ if (!selector) return undefined
104
124
 
105
125
  if (ruleMap.has(selector)) {
126
+ // Already tracked. Take an adoption ref if we don't already have one —
127
+ // the rule may have been inserted by a client-side css mixin first and
128
+ // the SSR'd style tag arrived afterwards (e.g. a streamed fragment).
129
+ if (!adoptedSelectors.has(selector)) {
130
+ let entry = ruleMap.get(selector)!
131
+ entry.count++
132
+ adoptedSelectors.add(selector)
133
+ }
106
134
  styleEl.remove()
107
- return
135
+ return selector
108
136
  }
109
137
 
110
138
  let cssText = styleEl.textContent?.trim() ?? ''
111
139
  if (cssText.length === 0) {
112
140
  styleEl.remove()
113
- return
141
+ return undefined
114
142
  }
115
143
 
116
144
  try {
@@ -118,9 +146,12 @@ export function createStyleManager(layer: string = 'rmx') {
118
146
  let index = sheet.cssRules.length
119
147
  sheet.insertRule(cssText, index)
120
148
  ruleMap.set(selector, { count: 1, index })
149
+ adoptedSelectors.add(selector)
121
150
  styleEl.remove()
151
+ return selector
122
152
  } catch {
123
153
  // If adoption fails, keep the <style> tag in the DOM so styles still apply.
154
+ return undefined
124
155
  }
125
156
  }
126
157
 
@@ -169,6 +200,7 @@ export function createStyleManager(layer: string = 'rmx') {
169
200
 
170
201
  // Remove from tracking
171
202
  ruleMap.delete(className)
203
+ adoptedSelectors.delete(className)
172
204
 
173
205
  // TODO: just search and remove, stop re-indexing
174
206
  if (!stylesheet) return
@@ -186,24 +218,69 @@ export function createStyleManager(layer: string = 'rmx') {
186
218
  function reset() {
187
219
  clearStylesheet()
188
220
  ruleMap.clear()
221
+ adoptedSelectors.clear()
189
222
  removeStylesheet()
190
223
  generation++
191
224
  }
192
225
 
193
- function adoptServerStyles(source: ServerStyleSource) {
226
+ function adoptServerStyles(source: ServerStyleSource): Set<string> {
194
227
  let styles = collectServerStyleTags(source)
228
+ let adopted = new Set<string>()
195
229
 
196
230
  for (let styleEl of styles) {
197
- adoptServerStyleTag(styleEl)
231
+ let selector = adoptServerStyleTag(styleEl)
232
+ if (selector) adopted.add(selector)
233
+ }
234
+
235
+ return adopted
236
+ }
237
+
238
+ // Snapshot the currently-adopted server selectors, adopt the incoming
239
+ // server styles additively, then release the adoption ref of any prior-only
240
+ // selectors.
241
+ //
242
+ // Refcount-aware semantics keep preserved DOM styled: a prior-only selector
243
+ // with no live css-mixin ref drops immediately, but one still referenced by
244
+ // an active mixin (e.g. inside a hydration region that the DOM diff skipped)
245
+ // stays in the stylesheet and is only fully removed when that mixin's
246
+ // `remove` event eventually fires. This avoids the FOUC that a hard
247
+ // `reset()` would cause between the style swap and the hydration re-render.
248
+ //
249
+ // Only adoption refs are considered — selectors that exist solely because a
250
+ // client-side css mixin inserted them (e.g. transient UI state never
251
+ // rendered by the server) are unaffected.
252
+ function replaceServerStyles(source: ServerStyleSource): void {
253
+ let prior = new Set(adoptedSelectors)
254
+ let adopted = adoptServerStyles(source)
255
+ for (let selector of prior) {
256
+ if (!adopted.has(selector)) {
257
+ adoptedSelectors.delete(selector)
258
+ remove(selector)
259
+ }
198
260
  }
199
261
  }
200
262
 
263
+ function selectors(): IterableIterator<string> {
264
+ return ruleMap.keys()
265
+ }
266
+
201
267
  function dispose() {
202
268
  removeStylesheet()
203
269
  // Clear internal state
204
270
  ruleMap.clear()
271
+ adoptedSelectors.clear()
205
272
  generation++
206
273
  }
207
274
 
208
- return { insert, remove, has, getGeneration, reset, adoptServerStyles, dispose }
275
+ return {
276
+ insert,
277
+ remove,
278
+ has,
279
+ getGeneration,
280
+ reset,
281
+ adoptServerStyles,
282
+ replaceServerStyles,
283
+ selectors,
284
+ dispose,
285
+ }
209
286
  }
@@ -0,0 +1,107 @@
1
+ # Test
2
+
3
+ When writing tests, use `root.flush()` to synchronously execute all pending updates and tasks. This ensures the DOM and component state are fully synchronized before making assertions.
4
+
5
+ ## Basic Testing Pattern
6
+
7
+ The main use case is flushing after events that call `handle.update()`. Since updates are asynchronous, you need to flush to ensure the DOM reflects the changes:
8
+
9
+ ```tsx
10
+ function Counter(handle: Handle) {
11
+ let count = 0
12
+
13
+ return () => (
14
+ <button
15
+ mix={[
16
+ on('click', () => {
17
+ count++
18
+ handle.update()
19
+ }),
20
+ ]}
21
+ >
22
+ Count: {count}
23
+ </button>
24
+ )
25
+ }
26
+
27
+ // In your test
28
+ let container = document.createElement('div')
29
+ let root = createRoot(container)
30
+
31
+ root.render(<Counter />)
32
+ root.flush() // Ensure initial render completes
33
+
34
+ let button = container.querySelector('button')
35
+ button.click() // Triggers handle.update()
36
+ root.flush() // Flush to apply the update
37
+
38
+ expect(container.textContent).toBe('Count: 1')
39
+ ```
40
+
41
+ ## Why Flush After Initial Render?
42
+
43
+ You should also flush after the initial `root.render()` to ensure event listeners are attached and the DOM is ready for interaction:
44
+
45
+ ```tsx
46
+ let root = createRoot(container)
47
+ root.render(<MyComponent />)
48
+ root.flush() // Event listeners now attached
49
+
50
+ // Safe to interact
51
+ container.querySelector('button').click()
52
+ ```
53
+
54
+ ## Testing Async Operations
55
+
56
+ For components with async operations in `queueTask`, flush after each step:
57
+
58
+ ```tsx
59
+ function AsyncLoader(handle: Handle) {
60
+ let data: string | null = null
61
+
62
+ handle.queueTask(async (signal) => {
63
+ let response = await fetch('/api/data', { signal })
64
+ let json = await response.json()
65
+ if (signal.aborted) return
66
+ data = json.value
67
+ handle.update()
68
+ })
69
+
70
+ return () => <div>{data ?? 'Loading...'}</div>
71
+ }
72
+
73
+ // In your test (with mocked fetch)
74
+ let root = createRoot(container)
75
+ root.render(<AsyncLoader />)
76
+ root.flush()
77
+
78
+ expect(container.textContent).toBe('Loading...')
79
+
80
+ // After fetch resolves
81
+ await waitForFetch()
82
+ root.flush()
83
+
84
+ expect(container.textContent).toBe('Expected data')
85
+ ```
86
+
87
+ ## Testing Component Removal
88
+
89
+ Use `root.dispose()` to clean up and verify cleanup behavior:
90
+
91
+ ```tsx
92
+ let root = createRoot(container)
93
+ root.render(<MyComponent />)
94
+ root.flush()
95
+
96
+ // Verify setup behavior
97
+ expect(container.querySelector('.content')).toBeTruthy()
98
+
99
+ // Remove and verify cleanup
100
+ root.dispose()
101
+ expect(container.innerHTML).toBe('')
102
+ ```
103
+
104
+ ## See Also
105
+
106
+ - [Getting Started](https://github.com/remix-run/remix/blob/main/packages/ui/docs/getting-started.md) - Root methods reference
107
+ - [Handle API](https://github.com/remix-run/remix/blob/main/packages/ui/docs/handle.md) - `handle.queueTask()` behavior
package/src/test/utils.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { stripFlushMarkers } from '../runtime/stream-protocol.ts'
2
+
1
3
  export type Assert<T extends true> = T
2
4
 
3
5
  export type Equal<X, Y> =
@@ -14,7 +16,7 @@ export async function drain(stream: ReadableStream<Uint8Array>): Promise<string>
14
16
  html += decoder.decode(value)
15
17
  }
16
18
 
17
- return html
19
+ return stripFlushMarkers(html)
18
20
  }
19
21
 
20
22
  export function readChunks(stream: ReadableStream<Uint8Array>): AsyncGenerator<string, void, void> {
@@ -0,0 +1,103 @@
1
+ # theme
2
+
3
+ `theme` provides Remix UI design tokens, theme creation, glyph contracts, and the built-in `RMX_01` preset. Use it to install CSS custom properties once and consume typed token references in component styles.
4
+
5
+ ## Usage
6
+
7
+ ```tsx
8
+ import { createTheme, theme } from 'remix/ui/theme'
9
+ import { css } from 'remix/ui'
10
+
11
+ let Theme = createTheme({
12
+ space: {
13
+ none: '0px',
14
+ px: '1px',
15
+ xs: '2px',
16
+ sm: '4px',
17
+ md: '8px',
18
+ lg: '12px',
19
+ xl: '16px',
20
+ xxl: '24px',
21
+ },
22
+ radius: { none: '0px', sm: '4px', md: '8px', lg: '12px', xl: '16px', full: '9999px' },
23
+ fontFamily: { sans: 'Inter, sans-serif', mono: 'monospace' },
24
+ fontSize: {
25
+ xxxs: '10px',
26
+ xxs: '11px',
27
+ xs: '12px',
28
+ sm: '14px',
29
+ md: '16px',
30
+ lg: '18px',
31
+ xl: '20px',
32
+ xxl: '28px',
33
+ },
34
+ lineHeight: { tight: '1.2', normal: '1.5', relaxed: '1.7' },
35
+ letterSpacing: { tight: '-0.02em', normal: '0', meta: '0.06em', wide: '0.08em' },
36
+ fontWeight: { normal: '400', medium: '500', semibold: '600', bold: '700' },
37
+ control: { height: { sm: '28px', md: '32px', lg: '36px' } },
38
+ surface: { lvl0: '#fff', lvl1: '#f8fafc', lvl2: '#f5f5f5', lvl3: '#f1f5f9', lvl4: '#e9eef6' },
39
+ shadow: { xs: 'none', sm: 'none', md: 'none', lg: 'none', xl: 'none' },
40
+ colors: {
41
+ text: { primary: '#111827', secondary: '#374151', muted: '#6b7280', link: '#2563eb' },
42
+ border: { subtle: '#e5e7eb', default: '#d1d5db', strong: '#9ca3af' },
43
+ focus: { ring: '#3b82f6' },
44
+ overlay: { scrim: 'rgb(0 0 0 / 0.45)' },
45
+ action: {
46
+ primary: {
47
+ background: '#2563eb',
48
+ backgroundHover: '#1d4ed8',
49
+ backgroundActive: '#1e40af',
50
+ foreground: '#fff',
51
+ border: '#2563eb',
52
+ },
53
+ secondary: {
54
+ background: '#fff',
55
+ backgroundHover: '#f8fafc',
56
+ backgroundActive: '#f1f5f9',
57
+ foreground: '#111827',
58
+ border: '#d1d5db',
59
+ },
60
+ danger: {
61
+ background: '#dc2626',
62
+ backgroundHover: '#b91c1c',
63
+ backgroundActive: '#991b1b',
64
+ foreground: '#fff',
65
+ border: '#dc2626',
66
+ },
67
+ },
68
+ },
69
+ })
70
+
71
+ let card = css({
72
+ backgroundColor: theme.surface.lvl0,
73
+ color: theme.colors.text.primary,
74
+ })
75
+
76
+ function Layout() {
77
+ return (
78
+ <body>
79
+ <Theme />
80
+ <article mix={card}>Project status</article>
81
+ </body>
82
+ )
83
+ }
84
+ ```
85
+
86
+ ## `theme.*`
87
+
88
+ - `theme`: typed CSS variable reference contract, such as `theme.space.md` and `theme.colors.text.primary`.
89
+ - `createTheme(values, options?)`: creates a style component with `cssText`, `selector`, `values`, `vars`, and `Style`.
90
+ - `RMX_01`: built-in theme component.
91
+ - `RMX_01_GLYPHS`: built-in glyph sheet component.
92
+ - `glyphContract` and `glyphNames`: stable glyph ids and supported glyph names.
93
+ - `ThemeValues`, `ThemeVars`, `ThemeComponent`, `ThemeStyleProps`, `CreateThemeOptions`, and `ThemeMix`: public TypeScript types for custom themes.
94
+ - `GlyphName`, `GlyphSymbol`, and `GlyphValues`: public TypeScript types for glyph contracts.
95
+
96
+ ## Behavior Notes
97
+
98
+ - `theme` values are CSS variable references, not raw token values.
99
+ - `createTheme` serializes token values into CSS custom properties and renders a `<style data-rmx-theme>` tag.
100
+ - The default selector is `:root`; pass `selector` for scoped themes.
101
+ - The base reset is included by default, emitted in `rmx-reset`, and can be disabled with `reset: false`.
102
+ - The built-in components consume this token contract through their style mixins.
103
+ - Render `<RMX_01 />` and `<RMX_01_GLYPHS />` once when using the built-in theme and glyph preset.
@@ -4,7 +4,38 @@ export interface ThemeVariableTree {
4
4
  [key: string]: string | ThemeVariableTree
5
5
  }
6
6
 
7
- const themeVariableNames = {
7
+ type ThemeVariableGroup<key extends string> = Readonly<Record<key, string>>
8
+ type ThemeActionVariableGroup = ThemeVariableGroup<
9
+ 'background' | 'backgroundHover' | 'backgroundActive' | 'foreground' | 'border'
10
+ >
11
+
12
+ type ThemeVariableNames = {
13
+ readonly space: ThemeVariableGroup<'none' | 'px' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl'>
14
+ readonly radius: ThemeVariableGroup<'none' | 'sm' | 'md' | 'lg' | 'xl' | 'full'>
15
+ readonly fontFamily: ThemeVariableGroup<'sans' | 'mono'>
16
+ readonly fontSize: ThemeVariableGroup<'xxxs' | 'xxs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl'>
17
+ readonly lineHeight: ThemeVariableGroup<'tight' | 'normal' | 'relaxed'>
18
+ readonly letterSpacing: ThemeVariableGroup<'tight' | 'normal' | 'meta' | 'wide'>
19
+ readonly fontWeight: ThemeVariableGroup<'normal' | 'medium' | 'semibold' | 'bold'>
20
+ readonly control: {
21
+ readonly height: ThemeVariableGroup<'sm' | 'md' | 'lg'>
22
+ }
23
+ readonly surface: ThemeVariableGroup<'lvl0' | 'lvl1' | 'lvl2' | 'lvl3' | 'lvl4'>
24
+ readonly shadow: ThemeVariableGroup<'xs' | 'sm' | 'md' | 'lg' | 'xl'>
25
+ readonly colors: {
26
+ readonly text: ThemeVariableGroup<'primary' | 'secondary' | 'muted' | 'link'>
27
+ readonly border: ThemeVariableGroup<'subtle' | 'default' | 'strong'>
28
+ readonly focus: ThemeVariableGroup<'ring'>
29
+ readonly overlay: ThemeVariableGroup<'scrim'>
30
+ readonly action: {
31
+ readonly primary: ThemeActionVariableGroup
32
+ readonly secondary: ThemeActionVariableGroup
33
+ readonly danger: ThemeActionVariableGroup
34
+ }
35
+ }
36
+ }
37
+
38
+ const themeVariableNamesSource: ThemeVariableNames = {
8
39
  space: {
9
40
  none: '--rmx-space-none',
10
41
  px: '--rmx-space-px',
@@ -117,7 +148,9 @@ const themeVariableNames = {
117
148
  },
118
149
  },
119
150
  },
120
- } as const satisfies ThemeVariableTree
151
+ } satisfies ThemeVariableTree
152
+
153
+ export const themeVariableNames: ThemeVariableNames = themeVariableNamesSource
121
154
 
122
155
  type MapLeaves<source, leaf> = source extends string
123
156
  ? leaf
@@ -126,6 +159,7 @@ type MapLeaves<source, leaf> = source extends string
126
159
  }
127
160
 
128
161
  export type ThemeValue = string | number
162
+ export type ThemeContract = MapLeaves<ThemeVariableNames, string>
129
163
  export type ThemeValues = MapLeaves<typeof themeVariableNames, ThemeValue>
130
164
  export type ThemeVars = Readonly<Record<string, string>>
131
165
  export type CreateThemeOptions = {
@@ -146,7 +180,7 @@ export type ThemeComponent = ThemeRenderer & {
146
180
  vars: ThemeVars
147
181
  }
148
182
 
149
- export const theme = createThemeContract(themeVariableNames)
183
+ export const theme: ThemeContract = createThemeContract(themeVariableNames)
150
184
 
151
185
  export type ThemeUtility = ReturnType<typeof css>
152
186
 
@@ -158,8 +192,6 @@ type NestedThemeMix<value, depth extends number = 4> = depth extends 0
158
192
 
159
193
  export type ThemeMix = NestedThemeMix<ThemeMixLeaf>
160
194
 
161
- export { themeVariableNames }
162
-
163
195
  function createThemeContract<tree extends ThemeVariableTree>(tree: tree): MapLeaves<tree, string> {
164
196
  return mapTreeLeaves(tree, (variableName) => `var(${variableName})`) as MapLeaves<tree, string>
165
197
  }
@@ -32,7 +32,9 @@ export type GlyphContract = Readonly<Record<GlyphName, { id: string }>>
32
32
 
33
33
  const DEFAULT_GLYPH_ID_PREFIX = 'rmx-glyph'
34
34
 
35
- export const glyphContract = Object.freeze(createGlyphContract(DEFAULT_GLYPH_ID_PREFIX))
35
+ export const glyphContract: GlyphContract = Object.freeze(
36
+ createGlyphContract(DEFAULT_GLYPH_ID_PREFIX),
37
+ )
36
38
 
37
39
  function createGlyphIds(idPrefix: string): Record<GlyphName, string> {
38
40
  return Object.fromEntries(glyphNames.map((name) => [name, `${idPrefix}-${name}`])) as Record<
@@ -1,9 +1,11 @@
1
1
  import { createGlyphSheet } from '../../../components/glyph/glyph.tsx'
2
2
  import { createTheme } from '../../runtime.ts'
3
+ import type { GlyphSheetComponent } from '../../../components/glyph/glyph.tsx'
4
+ import type { ThemeComponent } from '../../contract.ts'
3
5
 
4
6
  import { glyphValues } from './glyphs.tsx'
5
7
 
6
- export const RMX_01 = createTheme({
8
+ export const RMX_01: ThemeComponent = createTheme({
7
9
  space: {
8
10
  none: '0px',
9
11
  px: '1px',
@@ -118,4 +120,4 @@ export const RMX_01 = createTheme({
118
120
  },
119
121
  })
120
122
 
121
- export const RMX_01_GLYPHS = createGlyphSheet(glyphValues)
123
+ export const RMX_01_GLYPHS: GlyphSheetComponent = createGlyphSheet(glyphValues)
@@ -1,5 +1,6 @@
1
1
  import { createElement, type Handle } from '@remix-run/ui'
2
2
 
3
+ import { REMIX_UI_RESET_LAYER, REMIX_UI_STYLE_LAYER } from '../style/layers.ts'
3
4
  import {
4
5
  theme,
5
6
  themeVariableNames,
@@ -76,7 +77,9 @@ function serializeThemeCss(selector: string, vars: ThemeVars, options: { reset:
76
77
  let blocks = [`${selector} {\n${lines}\n}`]
77
78
 
78
79
  if (options.reset) {
79
- blocks.push(serializeThemeResetCss(selector))
80
+ let resetCss = serializeThemeResetCss(selector)
81
+ blocks.push(`@layer ${REMIX_UI_RESET_LAYER}, ${REMIX_UI_STYLE_LAYER};`)
82
+ blocks.push(`@layer ${REMIX_UI_RESET_LAYER} { ${resetCss} }`)
80
83
  }
81
84
 
82
85
  return blocks.join('\n\n')
@@ -0,0 +1,33 @@
1
+ # scroll-lock
2
+
3
+ `scroll-lock` locks document scrolling while a floating or modal surface is open. Use `lockScroll` directly for imperative flows or `lockScrollOnToggle` for popover-style elements that emit `beforetoggle`.
4
+
5
+ ## Usage
6
+
7
+ ```tsx
8
+ import { lockScroll, lockScrollOnToggle } from 'remix/ui/scroll-lock'
9
+
10
+ function DialogSurface() {
11
+ return <div popover="auto" mix={lockScrollOnToggle()} />
12
+ }
13
+
14
+ function openModal() {
15
+ let unlock = lockScroll()
16
+
17
+ // Later, when the modal closes:
18
+ unlock()
19
+ }
20
+ ```
21
+
22
+ ## API
23
+
24
+ - `lockScroll(document?)`: locks the target document and returns an idempotent unlock function.
25
+ - `lockScrollOnToggle()`: mixin that locks on `beforetoggle` open, unlocks on close, and releases the lock when the host unmounts.
26
+
27
+ ## Behavior Notes
28
+
29
+ - The lock stores and restores the document element's inline `overflow` and `scrollbarGutter`.
30
+ - Scroll position is restored when the last active lock is released.
31
+ - Multiple locks are reference-counted, so the document stays locked until every unlock function has run.
32
+ - When a scrollbar is present and computed `scrollbar-gutter` is `auto`, the document reserves a stable gutter while locked.
33
+ - `lockScrollOnToggle` uses the host element's owner document.
@@ -1,4 +1,4 @@
1
- import { createMixin, on, type ElementProps } from '@remix-run/ui'
1
+ import { createMixin, on, type ElementProps, type MixinFactory } from '@remix-run/ui'
2
2
 
3
3
  type ScrollLockState = {
4
4
  count: number
@@ -10,7 +10,7 @@ type ScrollLockState = {
10
10
 
11
11
  const scrollLocks = new WeakMap<Document, ScrollLockState>()
12
12
 
13
- export function lockScroll(targetDocument = globalThis.document) {
13
+ export function lockScroll(targetDocument: Document | undefined = globalThis.document): () => void {
14
14
  if (!targetDocument?.body || !targetDocument.defaultView) {
15
15
  return () => {}
16
16
  }
@@ -74,7 +74,11 @@ export function lockScroll(targetDocument = globalThis.document) {
74
74
  }
75
75
  }
76
76
 
77
- export const lockScrollOnToggle = createMixin<HTMLElement, [], ElementProps>((handle) => {
77
+ export const lockScrollOnToggle: MixinFactory<HTMLElement, [], ElementProps> = createMixin<
78
+ HTMLElement,
79
+ [],
80
+ ElementProps
81
+ >((handle) => {
78
82
  let unlockScroll = () => {}
79
83
 
80
84
  handle.signal.addEventListener('abort', () => {