@remix-run/ui 0.4.0 → 0.5.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 (189) hide show
  1. package/README.md +74 -1
  2. package/dist/accordion/index.js +2 -2
  3. package/dist/accordion/primitives.d.ts +1 -1
  4. package/dist/accordion/primitives.js.map +1 -1
  5. package/dist/anchor/index.js.map +1 -1
  6. package/dist/animation/animate-layout-mixin.js +1 -1
  7. package/dist/animation/animate-layout-mixin.js.map +1 -1
  8. package/dist/animation/animate-mixins.js +2 -2
  9. package/dist/animation/animate-mixins.js.map +1 -1
  10. package/dist/animation/demos/color-interpolation.js +1 -1
  11. package/dist/animation/demos/color-interpolation.js.map +1 -1
  12. package/dist/animation/demos/drag-release.d.ts +1 -1
  13. package/dist/animation/demos/hold-to-confirm.d.ts +3 -3
  14. package/dist/animation/demos/material-ripple.js +1 -3
  15. package/dist/animation/demos/material-ripple.js.map +1 -1
  16. package/dist/animation/index.js +4 -4
  17. package/dist/animation/spring.js.map +1 -1
  18. package/dist/breadcrumbs/index.js +2 -2
  19. package/dist/button/index.js +25 -25
  20. package/dist/button/index.js.map +1 -1
  21. package/dist/checkbox/index.js +11 -9
  22. package/dist/checkbox/index.js.map +1 -1
  23. package/dist/combobox/index.js +4 -4
  24. package/dist/combobox/index.js.map +1 -1
  25. package/dist/combobox/primitives.d.ts +1 -1
  26. package/dist/combobox/primitives.js +5 -5
  27. package/dist/combobox/primitives.js.map +1 -1
  28. package/dist/dev/refresh.d.ts +1 -0
  29. package/dist/dev/refresh.js +2 -0
  30. package/dist/dev/refresh.js.map +1 -0
  31. package/dist/index.d.ts +1 -1
  32. package/dist/index.js +14 -14
  33. package/dist/input/index.js +9 -9
  34. package/dist/input/index.js.map +1 -1
  35. package/dist/jsx-runtime.js +2 -2
  36. package/dist/listbox/index.js +2 -2
  37. package/dist/menu/hover-aim.js.map +1 -1
  38. package/dist/menu/index.js +3 -3
  39. package/dist/menu/primitives.d.ts +1 -1
  40. package/dist/menu/primitives.js +6 -6
  41. package/dist/menu/primitives.js.map +1 -1
  42. package/dist/popover/index.js +3 -3
  43. package/dist/popover/scroll-lock.js.map +1 -1
  44. package/dist/radio/index.js +7 -7
  45. package/dist/radio/index.js.map +1 -1
  46. package/dist/runtime/client-entry-boundary.d.ts +14 -0
  47. package/dist/runtime/client-entry-boundary.js +29 -0
  48. package/dist/runtime/client-entry-boundary.js.map +1 -0
  49. package/dist/runtime/component.d.ts +19 -4
  50. package/dist/runtime/component.js +39 -9
  51. package/dist/runtime/component.js.map +1 -1
  52. package/dist/runtime/core/attributes.js +36 -3
  53. package/dist/runtime/core/attributes.js.map +1 -1
  54. package/dist/runtime/core/children.d.ts +2 -0
  55. package/dist/runtime/core/children.js +26 -2
  56. package/dist/runtime/core/children.js.map +1 -1
  57. package/dist/runtime/core/props.js +2 -2
  58. package/dist/runtime/core/vnode.d.ts +3 -3
  59. package/dist/runtime/core/vnode.js +22 -1
  60. package/dist/runtime/core/vnode.js.map +1 -1
  61. package/dist/runtime/create-element.d.ts +2 -2
  62. package/dist/runtime/create-element.js +3 -2
  63. package/dist/runtime/create-element.js.map +1 -1
  64. package/dist/runtime/diff-dom.js +294 -219
  65. package/dist/runtime/diff-dom.js.map +1 -1
  66. package/dist/runtime/diff-props.js +3 -3
  67. package/dist/runtime/dom.d.ts +12 -0
  68. package/dist/runtime/element-function.d.ts +3 -0
  69. package/dist/runtime/element-function.js +2 -0
  70. package/dist/runtime/element-function.js.map +1 -0
  71. package/dist/runtime/form-navigation.d.ts +25 -0
  72. package/dist/runtime/form-navigation.js +132 -0
  73. package/dist/runtime/form-navigation.js.map +1 -0
  74. package/dist/runtime/frame-resolution.d.ts +5 -0
  75. package/dist/runtime/frame-resolution.js +10 -0
  76. package/dist/runtime/frame-resolution.js.map +1 -0
  77. package/dist/runtime/frame.d.ts +58 -15
  78. package/dist/runtime/frame.js +367 -114
  79. package/dist/runtime/frame.js.map +1 -1
  80. package/dist/runtime/jsx.d.ts +1 -1
  81. package/dist/runtime/jsx.js +1 -1
  82. package/dist/runtime/jsx.js.map +1 -1
  83. package/dist/runtime/key.d.ts +1 -0
  84. package/dist/runtime/key.js +2 -0
  85. package/dist/runtime/key.js.map +1 -0
  86. package/dist/runtime/mixins/attrs-mixin.js +2 -2
  87. package/dist/runtime/mixins/link-mixin.js +4 -3
  88. package/dist/runtime/mixins/link-mixin.js.map +1 -1
  89. package/dist/runtime/mixins/mixin.d.ts +15 -10
  90. package/dist/runtime/mixins/mixin.js +4 -4
  91. package/dist/runtime/mixins/mixin.js.map +1 -1
  92. package/dist/runtime/mixins/on-mixin.js +1 -1
  93. package/dist/runtime/mixins/ref-mixin.js +1 -1
  94. package/dist/runtime/navigation.d.ts +4 -1
  95. package/dist/runtime/navigation.js +157 -32
  96. package/dist/runtime/navigation.js.map +1 -1
  97. package/dist/runtime/reconcile.d.ts +11 -10
  98. package/dist/runtime/reconcile.js +549 -421
  99. package/dist/runtime/reconcile.js.map +1 -1
  100. package/dist/runtime/refresh.d.ts +28 -0
  101. package/dist/runtime/refresh.js +31 -0
  102. package/dist/runtime/refresh.js.map +1 -0
  103. package/dist/runtime/render.js +1 -1
  104. package/dist/runtime/render.js.map +1 -1
  105. package/dist/runtime/run.d.ts +3 -1
  106. package/dist/runtime/run.js +15 -7
  107. package/dist/runtime/run.js.map +1 -1
  108. package/dist/runtime/scheduler.js +8 -16
  109. package/dist/runtime/scheduler.js.map +1 -1
  110. package/dist/runtime/to-vnode.d.ts +2 -2
  111. package/dist/runtime/to-vnode.js +68 -20
  112. package/dist/runtime/to-vnode.js.map +1 -1
  113. package/dist/runtime/vdom.d.ts +4 -2
  114. package/dist/runtime/vdom.js +87 -42
  115. package/dist/runtime/vdom.js.map +1 -1
  116. package/dist/runtime/vnode.d.ts +138 -67
  117. package/dist/runtime/vnode.js +17 -12
  118. package/dist/runtime/vnode.js.map +1 -1
  119. package/dist/select/index.js +11 -11
  120. package/dist/select/index.js.map +1 -1
  121. package/dist/select/primitives.d.ts +1 -1
  122. package/dist/select/primitives.js +6 -6
  123. package/dist/server/stream.js +20 -27
  124. package/dist/server/stream.js.map +1 -1
  125. package/dist/shared/focus-styles.d.ts +1 -1
  126. package/dist/shared/focus-styles.js +1 -1
  127. package/dist/shared/focus-styles.js.map +1 -1
  128. package/dist/shared/listbox-popover-styles.js +1 -1
  129. package/dist/shared/style-values.js +26 -26
  130. package/dist/shared/style-values.js.map +1 -1
  131. package/dist/style/css-mixin.js +3 -3
  132. package/dist/style/index.js +2 -2
  133. package/dist/style/style.js +25 -6
  134. package/dist/style/style.js.map +1 -1
  135. package/dist/style/stylesheet.d.ts +0 -1
  136. package/dist/style/stylesheet.js +13 -54
  137. package/dist/style/stylesheet.js.map +1 -1
  138. package/dist/tabs/index.js +11 -11
  139. package/dist/tabs/index.js.map +1 -1
  140. package/dist/tabs/primitives.d.ts +1 -1
  141. package/dist/test.js +1 -1
  142. package/dist/toggle/index.js +9 -9
  143. package/dist/toggle/index.js.map +1 -1
  144. package/dist/toggle/primitives.d.ts +1 -1
  145. package/package.json +10 -7
  146. package/src/animation/animate-mixins.ts +3 -3
  147. package/src/animation/demos/color-interpolation.tsx +1 -1
  148. package/src/button/index.ts +24 -24
  149. package/src/checkbox/index.ts +10 -8
  150. package/src/combobox/index.tsx +1 -1
  151. package/src/dev/refresh.ts +1 -0
  152. package/src/index.ts +1 -1
  153. package/src/input/index.ts +8 -8
  154. package/src/radio/index.ts +6 -5
  155. package/src/runtime/client-entry-boundary.ts +54 -0
  156. package/src/runtime/component.ts +65 -16
  157. package/src/runtime/core/attributes.ts +35 -1
  158. package/src/runtime/core/children.ts +24 -2
  159. package/src/runtime/core/vnode.ts +25 -4
  160. package/src/runtime/create-element.ts +6 -5
  161. package/src/runtime/demos/readme.demo.tsx +4 -4
  162. package/src/runtime/diff-dom.ts +358 -243
  163. package/src/runtime/dom.ts +331 -214
  164. package/src/runtime/element-function.ts +1 -0
  165. package/src/runtime/event-listeners.ts +1 -1
  166. package/src/runtime/form-navigation.ts +184 -0
  167. package/src/runtime/frame-resolution.ts +13 -0
  168. package/src/runtime/frame.ts +482 -124
  169. package/src/runtime/jsx.ts +3 -5
  170. package/src/runtime/key.ts +1 -0
  171. package/src/runtime/mixins/link-mixin.ts +1 -0
  172. package/src/runtime/mixins/mixin.ts +21 -18
  173. package/src/runtime/navigation.ts +204 -30
  174. package/src/runtime/reconcile.ts +762 -759
  175. package/src/runtime/refresh.ts +46 -0
  176. package/src/runtime/run.ts +12 -2
  177. package/src/runtime/scheduler.ts +9 -29
  178. package/src/runtime/to-vnode.ts +85 -23
  179. package/src/runtime/vdom.ts +89 -70
  180. package/src/runtime/vnode.ts +192 -88
  181. package/src/select/index.tsx +9 -9
  182. package/src/server/stream.ts +22 -28
  183. package/src/shared/focus-styles.ts +1 -1
  184. package/src/shared/style-values.ts +26 -26
  185. package/src/style/style.ts +25 -6
  186. package/src/style/stylesheet.ts +32 -57
  187. package/src/tabs/index.tsx +12 -11
  188. package/src/test/README.md +151 -60
  189. package/src/toggle/index.tsx +9 -7
@@ -1,6 +1,26 @@
1
1
  import { REMIX_UI_STYLE_LAYER } from './layers.ts'
2
2
 
3
- type RuleEntry = { count: number; index: number }
3
+ // # Style ownership model
4
+ //
5
+ // Rules are content-addressed: a class name is a hash of the style object that
6
+ // produced it, so `.rmxc-x` always means the same declarations. A rule can
7
+ // never become *wrong* — only unused. That observation drives a two-tier
8
+ // lifetime model on a single document-level registry:
9
+ //
10
+ // - **Server-adopted rules are pinned.** Once a `<style data-rmx>` tag is
11
+ // adopted, its rule stays for the life of the manager. Frame reloads,
12
+ // island hydration, and streamed templates never need to agree on which
13
+ // scope "owns" a shared rule — adoption is additive and idempotent, and the
14
+ // registry is bounded by the set of unique style objects the server ever
15
+ // renders, not by render count.
16
+ //
17
+ // - **Client-inserted rules are refcounted.** Dynamic style objects (e.g.
18
+ // interpolated values) mint a new class per distinct value, so rules that
19
+ // only ever existed client-side are dropped when the last css mixin using
20
+ // them releases its ref. A refcounted rule upgrades to pinned if a server
21
+ // tag for the same selector is adopted later.
22
+
23
+ type RuleEntry = { count: number; index: number; pinned: boolean }
4
24
  export type ServerStyleSource = ParentNode | Iterable<Node>
5
25
 
6
26
  export interface StyleManager {
@@ -10,7 +30,6 @@ export interface StyleManager {
10
30
  getGeneration(): number
11
31
  reset(): void
12
32
  adoptServerStyles(source: ServerStyleSource): Set<string>
13
- replaceServerStyles(source: ServerStyleSource): void
14
33
  selectors(): IterableIterator<string>
15
34
  dispose(): void
16
35
  }
@@ -84,17 +103,8 @@ export function createStyleManager(layer: string = REMIX_UI_STYLE_LAYER): StyleM
84
103
  let stylesheet: CSSStyleSheet | null = null
85
104
  let generation = 0
86
105
 
87
- // Track usage count and rule index per className
88
- // Using an object to track both count and index together
89
106
  let ruleMap = new Map<string, RuleEntry>()
90
107
 
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
-
98
108
  function getStylesheet(): CSSStyleSheet {
99
109
  if (!stylesheet) {
100
110
  stylesheet = new CSSStyleSheet()
@@ -122,15 +132,11 @@ export function createStyleManager(layer: string = REMIX_UI_STYLE_LAYER): StyleM
122
132
  let selector = getStyleSelector(styleEl)
123
133
  if (!selector) return undefined
124
134
 
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
- }
135
+ let entry = ruleMap.get(selector)
136
+ if (entry) {
137
+ // The rule already exists either a previous adoption or a client-side
138
+ // css mixin inserted it first. Pin it so it outlives any mixin refs.
139
+ entry.pinned = true
134
140
  styleEl.remove()
135
141
  return selector
136
142
  }
@@ -145,8 +151,7 @@ export function createStyleManager(layer: string = REMIX_UI_STYLE_LAYER): StyleM
145
151
  let sheet = getStylesheet()
146
152
  let index = sheet.cssRules.length
147
153
  sheet.insertRule(cssText, index)
148
- ruleMap.set(selector, { count: 1, index })
149
- adoptedSelectors.add(selector)
154
+ ruleMap.set(selector, { count: 0, index, pinned: true })
150
155
  styleEl.remove()
151
156
  return selector
152
157
  } catch {
@@ -157,7 +162,7 @@ export function createStyleManager(layer: string = REMIX_UI_STYLE_LAYER): StyleM
157
162
 
158
163
  function has(className: string) {
159
164
  let entry = ruleMap.get(className)
160
- return entry !== undefined && entry.count > 0
165
+ return entry !== undefined && (entry.pinned || entry.count > 0)
161
166
  }
162
167
 
163
168
  function getGeneration() {
@@ -168,8 +173,8 @@ export function createStyleManager(layer: string = REMIX_UI_STYLE_LAYER): StyleM
168
173
  let entry = ruleMap.get(className)
169
174
 
170
175
  if (entry) {
171
- // Already exists, just increment count
172
- entry.count++
176
+ // Pinned rules are permanent; there is nothing to count.
177
+ if (!entry.pinned) entry.count++
173
178
  return
174
179
  }
175
180
 
@@ -179,13 +184,13 @@ export function createStyleManager(layer: string = REMIX_UI_STYLE_LAYER): StyleM
179
184
  // This may throw for invalid CSS. If it does, we intentionally let it
180
185
  // bubble so the rule is not tracked unless insertion actually succeeds.
181
186
  sheet.insertRule(`@layer ${getStyleLayerName(className, layer)} { ${rule} }`, index)
182
- ruleMap.set(className, { count: 1, index })
187
+ ruleMap.set(className, { count: 1, index, pinned: false })
183
188
  }
184
189
 
185
190
  function remove(className: string) {
186
191
  let entry = ruleMap.get(className)
187
192
 
188
- if (!entry) return
193
+ if (!entry || entry.pinned) return
189
194
 
190
195
  // Decrement count
191
196
  entry.count--
@@ -200,9 +205,7 @@ export function createStyleManager(layer: string = REMIX_UI_STYLE_LAYER): StyleM
200
205
 
201
206
  // Remove from tracking
202
207
  ruleMap.delete(className)
203
- adoptedSelectors.delete(className)
204
208
 
205
- // TODO: just search and remove, stop re-indexing
206
209
  if (!stylesheet) return
207
210
  stylesheet.deleteRule(indexToDelete)
208
211
 
@@ -218,7 +221,6 @@ export function createStyleManager(layer: string = REMIX_UI_STYLE_LAYER): StyleM
218
221
  function reset() {
219
222
  clearStylesheet()
220
223
  ruleMap.clear()
221
- adoptedSelectors.clear()
222
224
  removeStylesheet()
223
225
  generation++
224
226
  }
@@ -235,31 +237,6 @@ export function createStyleManager(layer: string = REMIX_UI_STYLE_LAYER): StyleM
235
237
  return adopted
236
238
  }
237
239
 
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
- }
260
- }
261
- }
262
-
263
240
  function selectors(): IterableIterator<string> {
264
241
  return ruleMap.keys()
265
242
  }
@@ -268,7 +245,6 @@ export function createStyleManager(layer: string = REMIX_UI_STYLE_LAYER): StyleM
268
245
  removeStylesheet()
269
246
  // Clear internal state
270
247
  ruleMap.clear()
271
- adoptedSelectors.clear()
272
248
  generation++
273
249
  }
274
250
 
@@ -279,7 +255,6 @@ export function createStyleManager(layer: string = REMIX_UI_STYLE_LAYER): StyleM
279
255
  getGeneration,
280
256
  reset,
281
257
  adoptServerStyles,
282
- replaceServerStyles,
283
258
  selectors,
284
259
  dispose,
285
260
  }
@@ -37,9 +37,9 @@ export interface TabPanelProps extends Omit<Props<'div'>, 'children'> {
37
37
  }
38
38
 
39
39
  const tabSliderBackground =
40
- 'linear-gradient(180deg, rgba(0, 0, 0, 0) 33%, rgba(0, 0, 0, 0.04) 100%), #FFFFFF'
40
+ 'linear-gradient(180deg, rgba(0, 0, 0, 0) 33%, light-dark(rgba(0, 0, 0, 0.04), rgba(255, 255, 255, 0.08)) 100%), light-dark(#FFFFFF, #1a1a1a)'
41
41
  const tabSliderShadow =
42
- '0 0 0 0.5px rgba(0, 0, 0, 0.06), 0 1px 1px -0.5px rgba(0, 0, 0, 0.12), 0 2px 2px -1px rgba(0, 0, 0, 0.12), 0 4px 4px -2px rgba(0, 0, 0, 0.12), inset 0 0 2px 1px #FFFFFF'
42
+ '0 0 0 0.5px light-dark(rgba(0, 0, 0, 0.06), rgba(255, 255, 255, 0.12)), 0 1px 1px -0.5px rgba(0, 0, 0, 0.12), 0 2px 2px -1px rgba(0, 0, 0, 0.12), 0 4px 4px -2px rgba(0, 0, 0, 0.12), inset 0 0 2px 1px light-dark(#FFFFFF, rgba(255, 255, 255, 0.08))'
43
43
 
44
44
  const tabsRootCss: CSSMixinDescriptor = css({
45
45
  '--rmx-tabs-height': '32px',
@@ -66,7 +66,8 @@ const tabsListCss: CSSMixinDescriptor = css({
66
66
  padding: 'var(--rmx-tabs-track-padding)',
67
67
  border: 0,
68
68
  borderRadius: '9999px',
69
- background: 'linear-gradient(180deg, rgba(0, 0, 0, 0.06) 0%, rgba(0, 0, 0, 0) 100%), #EBEBEB',
69
+ background:
70
+ 'linear-gradient(180deg, light-dark(rgba(0, 0, 0, 0.06), rgba(255, 255, 255, 0.08)) 0%, rgba(0, 0, 0, 0) 100%), light-dark(#EBEBEB, #2c2c2c)',
70
71
  boxShadow:
71
72
  'inset 0 0 4px 1px rgba(0, 0, 0, 0.08), inset 0 1px 1px rgba(0, 0, 0, 0.02), inset 0 2px 2px rgba(0, 0, 0, 0.02)',
72
73
  overflowX: 'auto',
@@ -81,7 +82,7 @@ const tabsListCss: CSSMixinDescriptor = css({
81
82
 
82
83
  const tabsTabCss: CSSMixinDescriptor = css({
83
84
  '--rmx-tabs-tab-focus-shadow':
84
- '0 0 0 1px #3573F6, var(--rmx-tabs-tab-shadow), 0 0 0 4px rgba(53, 115, 246, 0.1), 0 6px 32px 4px rgba(53, 115, 246, 0.08), inset 0 0 8px 1px rgba(53, 115, 246, 0.05)',
85
+ '0 0 0 1px light-dark(#3573F6, #6eaaff), var(--rmx-tabs-tab-shadow), 0 0 0 4px light-dark(rgba(53, 115, 246, 0.1), rgba(110, 170, 255, 0.18)), 0 6px 32px 4px light-dark(rgba(53, 115, 246, 0.08), rgba(110, 170, 255, 0.14)), inset 0 0 8px 1px light-dark(rgba(53, 115, 246, 0.05), rgba(110, 170, 255, 0.1))',
85
86
  appearance: 'none',
86
87
  margin: 0,
87
88
  boxSizing: 'border-box',
@@ -103,7 +104,7 @@ const tabsTabCss: CSSMixinDescriptor = css({
103
104
  borderRadius: '999px',
104
105
  background: 'transparent',
105
106
  boxShadow: 'var(--rmx-tabs-tab-shadow)',
106
- color: '#707070',
107
+ color: 'light-dark(#707070, #b3b3b3)',
107
108
  fontFamily: '"Inter Variable", Inter, ui-sans-serif, system-ui, sans-serif',
108
109
  fontStyle: 'normal',
109
110
  fontWeight: 500,
@@ -119,17 +120,17 @@ const tabsTabCss: CSSMixinDescriptor = css({
119
120
  verticalAlign: 'top',
120
121
  whiteSpace: 'nowrap',
121
122
  '&[data-state="inactive"]:hover:not(:disabled):not([aria-disabled="true"])': {
122
- background: 'rgba(16, 16, 16, 0.05)',
123
- color: '#101010',
123
+ background: 'light-dark(rgba(16, 16, 16, 0.05), rgba(236, 236, 236, 0.1))',
124
+ color: 'light-dark(#101010, #ececec)',
124
125
  },
125
126
  '&[data-state="inactive"]:active:not(:disabled):not([aria-disabled="true"])': {
126
- background: 'rgba(16, 16, 16, 0.08)',
127
+ background: 'light-dark(rgba(16, 16, 16, 0.08), rgba(236, 236, 236, 0.14))',
127
128
  },
128
129
  '&[data-state="active"]': {
129
130
  background: tabSliderBackground,
130
131
  '--rmx-tabs-tab-shadow': tabSliderShadow,
131
- color: '#101010',
132
- textShadow: '0 1px 0 #FFFFFF',
132
+ color: 'light-dark(#101010, #ececec)',
133
+ textShadow: '0 1px 0 light-dark(#FFFFFF, rgb(0 0 0 / 0.35))',
133
134
  },
134
135
  '&[data-state="active"]:hover:not(:disabled):not([aria-disabled="true"])': {
135
136
  background: tabSliderBackground,
@@ -149,7 +150,7 @@ const tabsTabCss: CSSMixinDescriptor = css({
149
150
 
150
151
  const tabsPanelCss: CSSMixinDescriptor = css({
151
152
  minWidth: 0,
152
- color: '#101010',
153
+ color: 'light-dark(#101010, #ececec)',
153
154
  fontFamily: '"Inter Variable", Inter, ui-sans-serif, system-ui, sans-serif',
154
155
  fontSize: '13px',
155
156
  lineHeight: '20px',
@@ -1,104 +1,195 @@
1
1
  # Test
2
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.
3
+ Use `render` from `remix/ui/test` as the primary way to test components in a live browser through a `remix/test` "browser" test (`*.test.browser.tsx`). It creates a DOM container, renders and flushes the initial component tree, and returns helpers for querying, interacting with, and cleaning up the rendered output.
4
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:
5
+ ## Rendering Components
8
6
 
9
7
  ```tsx
8
+ import { expect } from 'remix/assert'
9
+ import { describe, it } from 'remix/test'
10
+ import { on, type Handle } from 'remix/ui'
11
+ import { render } from 'remix/ui/test'
12
+
10
13
  function Counter(handle: Handle) {
11
14
  let count = 0
12
15
 
13
16
  return () => (
14
- <button
15
- mix={[
16
- on('click', () => {
17
- count++
18
- handle.update()
19
- }),
20
- ]}
21
- >
22
- Count: {count}
23
- </button>
17
+ <div>
18
+ <button
19
+ data-action="increment"
20
+ mix={[
21
+ on('click', () => {
22
+ count++
23
+ handle.update()
24
+ }),
25
+ ]}
26
+ >
27
+ Increment
28
+ </button>
29
+ <output data-testid="count">{count}</output>
30
+ </div>
24
31
  )
25
32
  }
26
33
 
27
- // In your test
28
- let container = document.createElement('div')
29
- let root = createRoot(container)
34
+ describe('Counter', () => {
35
+ it('increments the count', async (t) => {
36
+ let { $, act, cleanup } = render(<Counter />)
37
+ t.after(cleanup)
30
38
 
31
- root.render(<Counter />)
32
- root.flush() // Ensure initial render completes
39
+ expect($('[data-testid="count"]')?.textContent).toBe('0')
33
40
 
34
- let button = container.querySelector('button')
35
- button.click() // Triggers handle.update()
36
- root.flush() // Flush to apply the update
41
+ await act(() => $('[data-action="increment"]')?.click())
37
42
 
38
- expect(container.textContent).toBe('Count: 1')
43
+ expect($('[data-testid="count"]')?.textContent).toBe('1')
44
+ })
45
+ })
39
46
  ```
40
47
 
41
- ## Why Flush After Initial Render?
48
+ The initial render is already flushed, so the DOM and event listeners are ready when `render`
49
+ returns. Wrap interactions that may update component state in `act` and always await it. `act`
50
+ waits for the callback and then flushes pending component updates before the next assertion.
51
+
52
+ ### Testing Async Operations
42
53
 
43
- You should also flush after the initial `root.render()` to ensure event listeners are attached and the DOM is ready for interaction:
54
+ When an interaction starts an async operation, await that operation inside `act` so updates queued
55
+ after it resolves are flushed before making assertions:
44
56
 
45
57
  ```tsx
46
- let root = createRoot(container)
47
- root.render(<MyComponent />)
48
- root.flush() // Event listeners now attached
58
+ function UserLoader(handle: Handle<{ loadUser(): Promise<{ name: string }> }>) {
59
+ let name = 'No user loaded'
49
60
 
50
- // Safe to interact
51
- container.querySelector('button').click()
61
+ return () => (
62
+ <div>
63
+ <button
64
+ mix={[
65
+ on('click', async () => {
66
+ let user = await handle.props.loadUser()
67
+ name = user.name
68
+ handle.update()
69
+ }),
70
+ ]}
71
+ >
72
+ Load user
73
+ </button>
74
+ <output>{name}</output>
75
+ </div>
76
+ )
77
+ }
78
+
79
+ it('loads a user', async (t) => {
80
+ let userPromise = Promise.resolve({ name: 'Ada' })
81
+ let { $, act, cleanup } = render(<UserLoader loadUser={() => userPromise} />)
82
+ t.after(cleanup)
83
+
84
+ await act(async () => {
85
+ $('button')?.click()
86
+ await userPromise
87
+ })
88
+
89
+ expect($('output')?.textContent).toBe('Ada')
90
+ })
52
91
  ```
53
92
 
54
- ## Testing Async Operations
93
+ ### Querying Rendered Output
55
94
 
56
- For components with async operations in `queueTask`, flush after each step:
95
+ The returned query helpers are scoped to the rendered container so you don't have to worry about false positives outside of the rendered component:
57
96
 
58
97
  ```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
- })
98
+ let { $, $$, container, cleanup } = render(
99
+ <nav>
100
+ <a href="/one">One</a>
101
+ <a href="/two">Two</a>
102
+ </nav>,
103
+ )
69
104
 
70
- return () => <div>{data ?? 'Loading...'}</div>
71
- }
105
+ let nav = $('nav') // container.querySelector('nav')
106
+ let links = $$('a') // container.querySelectorAll('a')
72
107
 
73
- // In your test (with mocked fetch)
74
- let root = createRoot(container)
75
- root.render(<AsyncLoader />)
76
- root.flush()
108
+ expect(nav).toBeTruthy()
109
+ expect(links).toHaveLength(2)
110
+ expect(container.textContent).toContain('One')
77
111
 
78
- expect(container.textContent).toBe('Loading...')
112
+ cleanup()
113
+ ```
79
114
 
80
- // After fetch resolves
81
- await waitForFetch()
82
- root.flush()
115
+ Use `$` for the first matching element, `$$` for all matching elements, and `container` when a
116
+ DOM assertion does not fit a selector.
83
117
 
84
- expect(container.textContent).toBe('Expected data')
118
+ ### Cleaning Up
119
+
120
+ Register `cleanup` with the test context so it always runs, including when an assertion fails:
121
+
122
+ ```tsx
123
+ it('renders a dialog', (t) => {
124
+ let { $, cleanup } = render(<Dialog />)
125
+ t.after(cleanup)
126
+
127
+ expect($('[role="dialog"]')).toBeTruthy()
128
+ })
85
129
  ```
86
130
 
87
- ## Testing Component Removal
131
+ `cleanup` disposes the root and removes its container from the document. You can call it directly
132
+ when a test needs to assert cleanup behavior.
133
+
134
+ ### Rendering Into an Existing Container
88
135
 
89
- Use `root.dispose()` to clean up and verify cleanup behavior:
136
+ Pass a container when the test needs a specific DOM context:
90
137
 
91
138
  ```tsx
139
+ let container = document.createElement('section')
140
+ document.body.appendChild(container)
141
+
142
+ let result = render(<MyComponent />, { container })
143
+
144
+ expect(result.container).toBe(container)
145
+ result.cleanup()
146
+ ```
147
+
148
+ ## Advanced: Flushing Manually
149
+
150
+ Most component tests should use `render` and `act`. For lower-level runtime tests that need direct
151
+ control over rendering or scheduling, use the returned `root` and call `root.flush()` after an
152
+ operation that queues work:
153
+
154
+ ```tsx
155
+ let { root, container, cleanup } = render(<MyComponent value="first" />)
156
+
157
+ root.render(<MyComponent value="second" />)
158
+ root.flush()
159
+
160
+ expect(container.textContent).toContain('second')
161
+ cleanup()
162
+ ```
163
+
164
+ You can also create and manage a root directly:
165
+
166
+ ```tsx
167
+ import { createRoot } from 'remix/ui'
168
+
169
+ let container = document.createElement('div')
92
170
  let root = createRoot(container)
171
+
93
172
  root.render(<MyComponent />)
94
- root.flush()
173
+ root.flush() // Complete the initial render and attach event listeners
95
174
 
96
- // Verify setup behavior
97
- expect(container.querySelector('.content')).toBeTruthy()
175
+ container.querySelector('button')?.click()
176
+ root.flush() // Apply updates queued by the interaction
98
177
 
99
- // Remove and verify cleanup
100
178
  root.dispose()
101
- expect(container.innerHTML).toBe('')
179
+ ```
180
+
181
+ Manual flushing is also useful after an async operation resolves outside an `act` callback:
182
+
183
+ ```tsx
184
+ root.render(<AsyncLoader />)
185
+ root.flush()
186
+
187
+ expect(container.textContent).toBe('Loading...')
188
+
189
+ await waitForFetch()
190
+ root.flush()
191
+
192
+ expect(container.textContent).toBe('Expected data')
102
193
  ```
103
194
 
104
195
  ## See Also
@@ -17,13 +17,13 @@ const checkedSelector = '&:checked, &[aria-checked="true"], &[data-state="checke
17
17
  const checkedThumbSelector =
18
18
  '&:checked::before, &[aria-checked="true"]::before, &[data-state="checked"]::before'
19
19
  const uncheckedThumbBackground =
20
- 'linear-gradient(180deg, rgba(0, 0, 0, 0) 33%, rgba(0, 0, 0, 0.04) 100%), #FFFFFF'
20
+ 'linear-gradient(180deg, rgba(0, 0, 0, 0) 33%, light-dark(rgba(0, 0, 0, 0.04), rgba(255, 255, 255, 0.08)) 100%), light-dark(#FFFFFF, #1a1a1a)'
21
21
  const uncheckedThumbShadow =
22
- '0 0 0 0.5px rgba(0, 0, 0, 0.06), 0 1px 1px -0.5px rgba(0, 0, 0, 0.12), 0 2px 2px -1px rgba(0, 0, 0, 0.12), 0 4px 4px -2px rgba(0, 0, 0, 0.12), inset 0 0 2px 1px #FFFFFF'
22
+ '0 0 0 0.5px light-dark(rgba(0, 0, 0, 0.06), rgba(255, 255, 255, 0.12)), 0 1px 1px -0.5px rgba(0, 0, 0, 0.12), 0 2px 2px -1px rgba(0, 0, 0, 0.12), 0 4px 4px -2px rgba(0, 0, 0, 0.12), inset 0 0 2px 1px light-dark(#FFFFFF, rgba(255, 255, 255, 0.08))'
23
23
  const checkedThumbBackground =
24
- 'linear-gradient(180deg, rgba(112, 199, 84, 0) 25%, rgba(112, 199, 84, 0.25) 100%), #FFFFFF'
24
+ 'linear-gradient(180deg, rgba(112, 199, 84, 0) 25%, rgba(112, 199, 84, 0.25) 100%), light-dark(#FFFFFF, #f5fff0)'
25
25
  const checkedThumbShadow =
26
- '0 1px 2px -0.5px rgba(66, 134, 44, 0.6), 0 2px 4px -1px rgba(66, 134, 44, 0.6), 0 4px 6px -2px rgba(66, 134, 44, 0.6), 0 0 0 0.5px rgba(0, 0, 0, 0.28), inset 0 0 2px 1px #FFFFFF'
26
+ '0 1px 2px -0.5px rgba(66, 134, 44, 0.6), 0 2px 4px -1px rgba(66, 134, 44, 0.6), 0 4px 6px -2px rgba(66, 134, 44, 0.6), 0 0 0 0.5px light-dark(rgba(0, 0, 0, 0.28), rgba(255, 255, 255, 0.28)), inset 0 0 2px 1px light-dark(#FFFFFF, rgba(255, 255, 255, 0.5))'
27
27
 
28
28
  const toggleDefaultAttrs = createMixin<Element, [], ElementProps>((handle, hostType) => (props) => {
29
29
  if (hostType !== 'input') {
@@ -59,7 +59,8 @@ const baseStyles = {
59
59
  padding: 0,
60
60
  border: 0,
61
61
  borderRadius: '9999px',
62
- background: 'linear-gradient(180deg, rgba(0, 0, 0, 0.06) 0%, rgba(0, 0, 0, 0) 100%), #EBEBEB',
62
+ background:
63
+ 'linear-gradient(180deg, light-dark(rgba(0, 0, 0, 0.06), rgba(255, 255, 255, 0.08)) 0%, rgba(0, 0, 0, 0) 100%), light-dark(#EBEBEB, #2c2c2c)',
63
64
  boxShadow:
64
65
  'inset 0 0 4px 1px rgba(0, 0, 0, 0.08), inset 0 1px 1px rgba(0, 0, 0, 0.02), inset 0 2px 2px rgba(0, 0, 0, 0.02)',
65
66
  verticalAlign: 'middle',
@@ -82,9 +83,10 @@ const baseStyles = {
82
83
  opacity: 0.55,
83
84
  },
84
85
  [checkedSelector]: {
85
- background: 'linear-gradient(180deg, #70C754 0%, #70C754 100%)',
86
+ background:
87
+ 'linear-gradient(180deg, light-dark(#70C754, #8ee572) 0%, light-dark(#70C754, #8ee572) 100%)',
86
88
  boxShadow:
87
- '0 1px 0 rgba(255, 255, 255, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.4), 0 -4px 8px 2px #FFFFFF, 0 4px 8px 2px rgba(0, 0, 0, 0.05), 0 0 12px 1px rgba(112, 199, 84, 0.25), inset 0 0 4px 1px rgba(0, 0, 0, 0.25), inset 0 1px 1px rgba(0, 0, 0, 0.1), inset 0 2px 2px rgba(0, 0, 0, 0.1)',
89
+ '0 1px 0 rgba(255, 255, 255, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.4), 0 -4px 8px 2px light-dark(#FFFFFF, rgba(255, 255, 255, 0.18)), 0 4px 8px 2px rgba(0, 0, 0, 0.05), 0 0 12px 1px rgba(112, 199, 84, 0.25), inset 0 0 4px 1px rgba(0, 0, 0, 0.25), inset 0 1px 1px rgba(0, 0, 0, 0.1), inset 0 2px 2px rgba(0, 0, 0, 0.1)',
88
90
  },
89
91
  [checkedThumbSelector]: {
90
92
  background: checkedThumbBackground,