@octanejs/radix 0.1.2 → 0.1.4

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/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # @octanejs/radix
2
2
 
3
3
  [Radix UI Primitives](https://www.radix-ui.com/primitives) for the
4
- [octane](https://github.com/octanejs/octane) renderer — a port of
4
+ [octane](https://github.com/octanejs/octane) UI framework — a port of
5
5
  [`@radix-ui/react`](https://www.npmjs.com/org/radix-ui) (headless, accessible UI
6
6
  primitives) on octane's hooks. It mirrors the unified `radix-ui` package's shape: the
7
7
  low-level composition utilities are exported directly, and each component is a namespace
@@ -98,3 +98,9 @@ Remaining (documented in
98
98
  coverage for the overlay/portal components and Phase-5 polish. The port surfaced —
99
99
  and fixed, in octane itself — fourteen runtime/compiler parity bugs along the way;
100
100
  each is pinned by an octane regression test and a changeset.
101
+
102
+ ## Status
103
+
104
+ Current scope, known divergences, and verification status are tracked in the
105
+ generated [bindings status table](../../docs/bindings-status.md), sourced from
106
+ this package's [`status.json`](./status.json).
package/package.json CHANGED
@@ -1,8 +1,15 @@
1
1
  {
2
2
  "name": "@octanejs/radix",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
+ "octane": {
7
+ "hookSlots": {
8
+ "manual": [
9
+ "src"
10
+ ]
11
+ }
12
+ },
6
13
  "description": "Radix UI Primitives for the octane renderer — a port of @radix-ui/react (headless, accessible UI primitives) on octane's hooks, built on the same @octanejs/floating-ui substrate.",
7
14
  "author": {
8
15
  "name": "Dominic Gannaway",
@@ -28,11 +35,11 @@
28
35
  },
29
36
  "dependencies": {
30
37
  "aria-hidden": "^1.2.6",
31
- "@octanejs/floating-ui": "0.1.2",
32
- "octane": "0.1.3"
38
+ "@octanejs/floating-ui": "0.1.4",
39
+ "octane": "0.1.5"
33
40
  },
34
41
  "devDependencies": {
35
- "@tsrx/react": "^0.2.33",
42
+ "@tsrx/react": "^0.2.37",
36
43
  "esbuild": "^0.28.1",
37
44
  "radix-ui": "^1.4.3",
38
45
  "react": "^19.2.0",
package/src/Checkbox.ts CHANGED
@@ -2,15 +2,14 @@
2
2
  // .radix-primitives/packages/react/checkbox/src/checkbox.tsx). A `role=checkbox` button
3
3
  // (with `indeterminate` support) that — when inside a form — renders a hidden native
4
4
  // checkbox "bubble input" so native form machinery (FormData, validation, reset,
5
- // change listeners) reflects the state. The bubble input is uncontrolled; state syncs
6
- // imperatively via the native `checked` property setter + dispatched events.
5
+ // change listeners) reflects the state.
7
6
  //
8
- // octane adaptations (documented; both stem from octane's native-event /
9
- // no-controlled-inputs model — see docs/react-parity-migration-plan.md):
7
+ // octane adaptations (documented; see docs/react-parity-migration-plan.md):
10
8
  // - React's synthetic `event.isPropagationStopped()` → native `event.cancelBubble`.
11
- // - `defaultChecked` prop the native `checked` ATTRIBUTE (HTML's default-state
12
- // semantics; live state is driven through the property setter, which never touches
13
- // the attribute).
9
+ // - The source's uncontrolled bubble input (`defaultChecked` + prototype-descriptor
10
+ // `checked` writes) a live CONTROLLED `checked` prop: octane's React-parity
11
+ // controlled form components write the DOM property at commit and reassert it after
12
+ // every event flush, so the bubble effect only dispatches events.
14
13
  // - The bubble effect ALSO dispatches a native `change` event after the source's
15
14
  // `click`: React forms observe checkbox clicks through synthetic `onChange`, which
16
15
  // octane doesn't have — a native bubbling `change` gives octane `<form onChange>`
@@ -230,7 +229,6 @@ export function BubbleInput(props: any): any {
230
229
  control,
231
230
  hasConsumerStoppedPropagationRef,
232
231
  checked,
233
- defaultChecked,
234
232
  required,
235
233
  disabled,
236
234
  name,
@@ -244,23 +242,18 @@ export function BubbleInput(props: any): any {
244
242
  const prevChecked = usePrevious(checked, subSlot(slot, 'prev'));
245
243
  const controlSize = useSize(control, subSlot(slot, 'size'));
246
244
 
247
- // Bubble checked change to parents (e.g form change event)
245
+ // Bubble checked change to parents (e.g form change event). The controlled
246
+ // `checked` prop below keeps the input's DOM state in sync, so — unlike the
247
+ // source's uncontrolled input — this effect only dispatches the events
248
+ // (`indeterminate` stays imperative: it is property-only, as in the source).
248
249
  useEffect(
249
250
  () => {
250
251
  const input = bubbleInput;
251
252
  if (!input) return;
252
253
 
253
- const inputProto = window.HTMLInputElement.prototype;
254
- const descriptor = Object.getOwnPropertyDescriptor(
255
- inputProto,
256
- 'checked',
257
- ) as PropertyDescriptor;
258
- const setChecked = descriptor.set;
259
-
260
254
  const bubbles = !hasConsumerStoppedPropagationRef.current;
261
- if (prevChecked !== checked && setChecked) {
255
+ if (prevChecked !== checked) {
262
256
  input.indeterminate = isIndeterminate(checked);
263
- setChecked.call(input, isIndeterminate(checked) ? false : checked);
264
257
  input.dispatchEvent(new Event('click', { bubbles }));
265
258
  // octane adaptation: also fire the native `change` React's synthetic
266
259
  // onChange would have synthesised from the click (see file header).
@@ -271,16 +264,13 @@ export function BubbleInput(props: any): any {
271
264
  subSlot(slot, 'e:bubble'),
272
265
  );
273
266
 
274
- const defaultCheckedRef = useRef(
275
- isIndeterminate(checked) ? false : checked,
276
- subSlot(slot, 'default'),
277
- );
278
267
  return createElement(Primitive.input, {
279
268
  type: 'checkbox',
280
269
  'aria-hidden': true,
281
- // octane: the native `checked` ATTRIBUTE is HTML's default-checked state (there is
282
- // no `defaultChecked` prop outside React's controlled model).
283
- checked: (defaultChecked ?? defaultCheckedRef.current) || undefined,
270
+ // Live CONTROLLED checked (octane React-parity): the runtime writes the DOM
271
+ // property, mirrors only the INITIAL state to the attribute, and reasserts
272
+ // the property on every commit / after event flushes.
273
+ checked: isIndeterminate(checked) ? false : checked,
284
274
  required,
285
275
  disabled,
286
276
  name,
@@ -16,12 +16,11 @@
16
16
  // with the corresponding user prop via `composeEventHandlers` (React fires them in that
17
17
  // order for a native `input` event). The user's `onChange` prop is therefore folded
18
18
  // into the `input` binding and NOT bound to native `change` (native change = commit).
19
- // - React's CONTROLLED `value={char}` cell has no octane equivalent (octane inputs are
20
- // uncontrolled and native): the `value` prop renders as the native attribute, a layout
21
- // effect imperatively syncs the DOM `.value` property whenever the cell's char state
22
- // changes, and the composed input handler ends with React's controlled-value
23
- // REASSERTION (restore `.value` to the state char when they diverge e.g. an invalid
24
- // character that produced no state change).
19
+ // - React's CONTROLLED `value={char}` cell maps directly onto octane's controlled
20
+ // `value` prop (React-parity controlled form components): the runtime writes the DOM
21
+ // `.value` property at commit, reasserts it on every commit of the owning block, and
22
+ // restores it after each native event flush (React's controlled-value restoration —
23
+ // e.g. an invalid character that produced no state change snaps back automatically).
25
24
  // - The source's `unstable_createCollection` (state-backed, `.at`/`.from`/`.size`/
26
25
  // `.indexOf`) has no shared octane port (the shared collection.ts is the legacy API,
27
26
  // which registers items in passive effects and never re-renders consumers). Since the
@@ -638,24 +637,10 @@ export function OneTimePasswordFieldInput(props: any): any {
638
637
  setElement,
639
638
  subSlot(slot, 'refs'),
640
639
  );
640
+ // The cell's `value={char}` is live CONTROLLED (octane React-parity): the runtime
641
+ // keeps the DOM `.value` in sync and restores it after event flushes — no
642
+ // imperative sync or post-handler reassertion needed here.
641
643
  const char = context.value[index] ?? '';
642
- // Latest char for the post-handler controlled-value reassertion (octane adaptation).
643
- const charRef = useRef(char, subSlot(slot, 'char'));
644
- charRef.current = char;
645
-
646
- // octane adaptation: React's controlled `value={char}` → imperative `.value` property
647
- // sync when the cell's state char changes (the attribute alone can't update a dirty
648
- // input).
649
- useLayoutEffect(
650
- () => {
651
- const input = inputRef.current;
652
- if (input && input.value !== char) {
653
- input.value = char;
654
- }
655
- },
656
- [char],
657
- subSlot(slot, 'e:value'),
658
- );
659
644
 
660
645
  const keyboardActionTimeoutRef = useRef<number | null>(null, subSlot(slot, 'timeout'));
661
646
  useEffect(
@@ -807,16 +792,12 @@ export function OneTimePasswordFieldInput(props: any): any {
807
792
  }
808
793
  }),
809
794
  // octane adaptation: ONE native `input` binding runs the source's onInput
810
- // logic then its onChange logic (React's order for a native input event),
811
- // then re-asserts the controlled value (React's controlled-input
812
- // restoration, which runs regardless of handler outcomes).
795
+ // logic then its onChange logic (React's order for a native input event).
796
+ // React's controlled-input restoration is the runtime's: after the event
797
+ // flush, the controlled `value` snaps the DOM back to the rendered char.
813
798
  onInput: (event: Event) => {
814
799
  composeEventHandlers(onInputProp, handleInput)(event);
815
800
  composeEventHandlers(onChangeProp, handleChange)(event);
816
- const input = inputRef.current;
817
- if (input && input.value !== charRef.current) {
818
- input.value = charRef.current;
819
- }
820
801
  },
821
802
  onKeyDown: composeEventHandlers(props?.onKeyDown, (event: KeyboardEvent) => {
822
803
  switch (event.key) {
package/src/Radio.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  // Ported from @radix-ui/react-radio-group's internal radio (source:
2
2
  // .radix-primitives/packages/react/radio-group/src/radio.tsx). The single-radio
3
3
  // building block RadioGroup composes: a `role=radio` button + Presence indicator +
4
- // the hidden native-radio "bubble input" (same machinery as Checkbox). Same octane
5
- // adaptations as Checkbox.ts: `isPropagationStopped()` `event.cancelBubble`;
6
- // `defaultChecked` the native `checked` attribute; a native `change` dispatched
7
- // alongside the source's `click`.
4
+ // the hidden native-radio "bubble input" (same machinery as Checkbox a live
5
+ // CONTROLLED `checked` prop keeps the DOM in sync, the bubble effect only dispatches
6
+ // events). Same octane adaptations as Checkbox.ts: `isPropagationStopped()`
7
+ // `event.cancelBubble`; the source's uncontrolled input + descriptor `checked` writes
8
+ // → octane's controlled `checked`; a native `change` dispatched alongside the
9
+ // source's `click`.
8
10
  import { createElement, useEffect, useRef, useState } from 'octane';
9
11
 
10
12
  import { composeEventHandlers } from './compose-event-handlers';
@@ -199,22 +201,16 @@ export function RadioBubbleInput(props: any): any {
199
201
  const prevChecked = usePrevious(checked, subSlot(slot, 'prev'));
200
202
  const controlSize = useSize(control, subSlot(slot, 'size'));
201
203
 
202
- // Bubble checked change to parents (e.g form change event)
204
+ // Bubble checked change to parents (e.g form change event). The controlled
205
+ // `checked` prop below keeps the input's DOM state in sync, so this effect
206
+ // only dispatches the events (see Checkbox.ts).
203
207
  useEffect(
204
208
  () => {
205
209
  const input = bubbleInput;
206
210
  if (!input) return;
207
211
 
208
- const inputProto = window.HTMLInputElement.prototype;
209
- const descriptor = Object.getOwnPropertyDescriptor(
210
- inputProto,
211
- 'checked',
212
- ) as PropertyDescriptor;
213
- const setChecked = descriptor.set;
214
-
215
212
  const bubbles = !hasConsumerStoppedPropagationRef.current;
216
- if (prevChecked !== checked && setChecked) {
217
- setChecked.call(input, checked);
213
+ if (prevChecked !== checked) {
218
214
  input.dispatchEvent(new Event('click', { bubbles }));
219
215
  // octane adaptation: also fire the native `change` (see Checkbox.ts header).
220
216
  input.dispatchEvent(new Event('change', { bubbles }));
@@ -224,12 +220,12 @@ export function RadioBubbleInput(props: any): any {
224
220
  subSlot(slot, 'e:bubble'),
225
221
  );
226
222
 
227
- const defaultCheckedRef = useRef(checked, subSlot(slot, 'default'));
228
223
  return createElement(Primitive.input, {
229
224
  type: 'radio',
230
225
  'aria-hidden': true,
231
- // octane: native `checked` attribute = default-checked state (see Checkbox.ts).
232
- checked: defaultCheckedRef.current || undefined,
226
+ // Live CONTROLLED checked (octane React-parity see Checkbox.ts). The runtime's
227
+ // event-restore also re-syncs radio-group cousins after a dispatched `click`.
228
+ checked,
233
229
  required,
234
230
  disabled,
235
231
  name,
package/src/ScrollArea.ts CHANGED
@@ -249,7 +249,7 @@ function useStateMachine(
249
249
  machine: Record<string, Record<string, string>>,
250
250
  slot: symbol | undefined,
251
251
  ): [string, (event: string) => void] {
252
- return useReducer(
252
+ const [state, dispatch] = useReducer(
253
253
  (state: string, event: string): string => {
254
254
  const nextState = machine[state][event];
255
255
  return nextState ?? state;
@@ -257,6 +257,7 @@ function useStateMachine(
257
257
  initialState,
258
258
  slot,
259
259
  );
260
+ return [state, dispatch];
260
261
  }
261
262
 
262
263
  function ScrollbarScroll(props: any): any {
package/src/Select.ts CHANGED
@@ -18,13 +18,13 @@
18
18
  // - `SelectValue`'s `<React.Fragment key={placeholder|value}>` remount trick is dropped:
19
19
  // octane's value-hole reconciliation replaces the placeholder/value content directly.
20
20
  // - The bubble `<select>`: React's `defaultValue={selectValue}` + `key={nativeSelectKey}`
21
- // (re-built so the default option associates) has no octane equivalent (octane has no
22
- // controlled/default-value model inputs are native). Instead the select's `value` is
23
- // synced imperatively: a silent property write whenever the rendered option set changes
24
- // (mount/late option registration), while actual value CHANGES go through the native
25
- // `value` setter + a dispatched bubbling `change` event (as in the source) so octane
26
- // `<form onChange>` observes them. React's synthetic `onChange` on the select is the
27
- // native `change` event (a `<select>` is not a text input, so no `onInput` remap).
21
+ // (re-built so the default option associates) a live CONTROLLED `value` prop:
22
+ // octane's React-parity controlled form components re-project a select's value onto
23
+ // its options at every commit (so late-registering options associate without a
24
+ // rebuild) and restore it after event flushes. Value CHANGES additionally dispatch a
25
+ // bubbling native `change` event (as in the source) so octane `<form onChange>`
26
+ // observes them. React's synthetic `onChange` on the select is the native `change`
27
+ // event (a `<select>` is not a text input, so no `onInput` remap).
28
28
  // - jsdom-environment guards (same class as use-size.ts's ResizeObserver guard; no
29
29
  // behavior change in browsers): `hasPointerCapture`/`releasePointerCapture` and
30
30
  // `scrollIntoView` are called only when they exist.
@@ -113,7 +113,6 @@ interface SelectContextValue {
113
113
  form?: string;
114
114
  // Native `<option>` element descriptors registered by mounted ItemTexts.
115
115
  nativeOptions: Set<any>;
116
- nativeSelectKey: string;
117
116
  isFormControl: boolean;
118
117
  }
119
118
 
@@ -181,14 +180,6 @@ export function Provider(props: any): any {
181
180
  );
182
181
  const contentId = useId(subSlot(slot, 'contentId'));
183
182
 
184
- // The native `select` only associates the correct default value if the corresponding
185
- // `option` is rendered as a child **at the same time** as itself. Because it might take
186
- // a few renders for our items to gather the information to build the native `option`(s),
187
- // this key tracks the option set (the bubble select re-syncs its value off it).
188
- const nativeSelectKey = Array.from(nativeOptionsSet)
189
- .map((option: any) => option?.props?.value)
190
- .join(';');
191
-
192
183
  const handleNativeOptionAdd = useCallback(
193
184
  (option: any) => {
194
185
  setNativeOptionsSet((prev: Set<any>) => new Set(prev).add(option));
@@ -229,7 +220,6 @@ export function Provider(props: any): any {
229
220
  autoComplete,
230
221
  form,
231
222
  nativeOptions: nativeOptionsSet,
232
- nativeSelectKey,
233
223
  isFormControl,
234
224
  };
235
225
 
@@ -1690,7 +1680,7 @@ export function BubbleInput(props: any): any {
1690
1680
  const { __scopeSelect, ref: forwardedRef, ...selectProps } = props ?? {};
1691
1681
  const context = useSelectContext(BUBBLE_INPUT_NAME, __scopeSelect);
1692
1682
  const { value, onValueChange, required, disabled, name, autoComplete, form } = context;
1693
- const { nativeOptions, nativeSelectKey } = context;
1683
+ const { nativeOptions } = context;
1694
1684
  const ref = useRef<HTMLSelectElement | null>(null, subSlot(slot, 'ref'));
1695
1685
  const composedRefs = useComposedRefs(forwardedRef, ref, subSlot(slot, 'refs'));
1696
1686
  const selectValue = value ?? '';
@@ -1704,49 +1694,32 @@ export function BubbleInput(props: any): any {
1704
1694
  (option: any) => (option?.props?.value ?? '') === '',
1705
1695
  );
1706
1696
 
1707
- // Bubble value change to parents (e.g form change event)
1697
+ // Bubble value change to parents (e.g form change event). The controlled
1698
+ // `value` prop below keeps the select's DOM selection in sync (octane
1699
+ // re-projects it at every commit, covering mount + late option registration
1700
+ // — the source's `key={nativeSelectKey}` rebuild), so this effect only
1701
+ // dispatches the event.
1708
1702
  useEffect(
1709
1703
  () => {
1710
1704
  const select = ref.current;
1711
1705
  if (!select) return;
1712
1706
 
1713
- const selectProto = window.HTMLSelectElement.prototype;
1714
- const descriptor = Object.getOwnPropertyDescriptor(
1715
- selectProto,
1716
- 'value',
1717
- ) as PropertyDescriptor;
1718
- const setValue = descriptor.set;
1719
- if (prevValue !== selectValue && setValue) {
1720
- const event = new Event('change', { bubbles: true });
1721
- setValue.call(select, selectValue);
1722
- select.dispatchEvent(event);
1707
+ if (prevValue !== selectValue) {
1708
+ select.dispatchEvent(new Event('change', { bubbles: true }));
1723
1709
  }
1724
1710
  },
1725
1711
  [prevValue, selectValue],
1726
1712
  subSlot(slot, 'e:bubble'),
1727
1713
  );
1728
1714
 
1729
- // octane adaptation: React associates the default value by re-building the keyed
1730
- // `select` with `defaultValue` each time the option set changes; octane's native-input
1731
- // model has no defaultValue, so silently sync the `value` property once the rendered
1732
- // options can represent it (mount + whenever the option set changes).
1733
- useEffect(
1734
- () => {
1735
- const select = ref.current;
1736
- if (select && select.value !== selectValue) {
1737
- select.value = selectValue;
1738
- }
1739
- },
1740
- [nativeSelectKey, selectValue],
1741
- subSlot(slot, 'e:default'),
1742
- );
1743
-
1744
1715
  /**
1745
1716
  * We purposefully use a `select` here to support form autofill as much as
1746
1717
  * possible.
1747
1718
  *
1748
- * We purposefully do not set the `value` attribute here to allow the value
1749
- * to be set programmatically and bubble to any parent form `onChange` event.
1719
+ * The `value` is live CONTROLLED (octane React-parity): the runtime projects
1720
+ * it onto the options at every commit and restores it after event flushes;
1721
+ * value changes still bubble to any parent form `onChange` via the `change`
1722
+ * event dispatched above.
1750
1723
  *
1751
1724
  * We use visually hidden styles rather than `display: "none"` because
1752
1725
  * Safari autofill won't work otherwise.
@@ -1759,6 +1732,7 @@ export function BubbleInput(props: any): any {
1759
1732
  autoComplete,
1760
1733
  disabled,
1761
1734
  form,
1735
+ value: selectValue,
1762
1736
  // React's synthetic onChange on a `<select>` is the native `change` event.
1763
1737
  onChange: (event: Event) => onValueChange((event.target as HTMLSelectElement).value),
1764
1738
  ...selectProps,
package/src/Slider.ts CHANGED
@@ -4,9 +4,10 @@
4
4
  // Horizontal/Vertical orientation layers translate pointer positions and step keys;
5
5
  // Impl owns pointer capture + slide events; Track/Range/Thumb render the parts (thumb
6
6
  // positions are percentage-based with an in-bounds offset). Each Thumb renders a
7
- // hidden native input "bubble input" inside forms (value synced via the native
8
- // `value` setter + a dispatched bubbling `input` eventoctane's native `<form
9
- // onInput>` observes it directly, so no extra adaptation is needed here).
7
+ // hidden native input "bubble input" inside forms (a live CONTROLLED `value` — octane's
8
+ // React-parity controlled form components keep the DOM property in sync plus a
9
+ // dispatched bubbling `input` event, which octane's native `<form onInput>` observes
10
+ // directly, so no extra adaptation is needed here).
10
11
  import { createElement, useEffect, useMemo, useRef, useState } from 'octane';
11
12
 
12
13
  import { createCollection } from './collection';
@@ -588,19 +589,16 @@ export function BubbleInput(props: any): any {
588
589
  const composedRefs = useComposedRefs(ref, forwardedRef, subSlot(slot, 'refs'));
589
590
  const prevValue = usePrevious(value, subSlot(slot, 'prev'));
590
591
 
591
- // Bubble value change to parents (e.g form change event)
592
+ // Bubble value change to parents (e.g form change event). The controlled
593
+ // `value` prop below keeps the input's DOM value in sync (commit-time
594
+ // property write + reassert), so this effect only dispatches the event.
592
595
  useEffect(
593
596
  () => {
594
597
  const input = ref.current;
595
598
  if (!input) return;
596
599
 
597
- const inputProto = window.HTMLInputElement.prototype;
598
- const descriptor = Object.getOwnPropertyDescriptor(inputProto, 'value') as PropertyDescriptor;
599
- const setValue = descriptor.set;
600
- if (prevValue !== value && setValue) {
601
- const event = new Event('input', { bubbles: true });
602
- setValue.call(input, value);
603
- input.dispatchEvent(event);
600
+ if (prevValue !== value) {
601
+ input.dispatchEvent(new Event('input', { bubbles: true }));
604
602
  }
605
603
  },
606
604
  [prevValue, value],
@@ -610,10 +608,10 @@ export function BubbleInput(props: any): any {
610
608
  // We purposefully do not use `type="hidden"` here otherwise forms that wrap it
611
609
  // will not be able to access its value via the FormData API.
612
610
  //
613
- // octane: the source omits React's `value` prop (its controlled model would
614
- // swallow the programmatic dispatch) and uses `defaultValue`; the octane
615
- // equivalent of default-value semantics is the native `value` ATTRIBUTE, which
616
- // the property setter above never touches.
611
+ // octane: the source uses `defaultValue` + a prototype-descriptor `value` write
612
+ // (React's controlled model would swallow the programmatic dispatch); octane's
613
+ // live CONTROLLED `value` has no such conflict — the runtime keeps the DOM
614
+ // property in sync and the effect above dispatches the bubbling `input` event.
617
615
  return createElement(Primitive.input, {
618
616
  style: { display: 'none' },
619
617
  name,
package/src/Switch.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  // Ported from @radix-ui/react-switch (source:
2
2
  // .radix-primitives/packages/react/switch/src/switch.tsx). A `role=switch` button with
3
3
  // a Thumb; inside a form it renders the hidden native-checkbox "bubble input" (same
4
- // machinery as Checkbox — uncontrolled input, imperative `checked` setter + dispatched
5
- // events). Same octane adaptations as Checkbox.ts: `isPropagationStopped()` →
6
- // `event.cancelBubble`; `defaultChecked` → the native `checked` attribute; a native
7
- // `change` dispatched alongside the source's `click`.
4
+ // machinery as Checkbox — a live CONTROLLED `checked` prop keeps the DOM in sync, the
5
+ // bubble effect only dispatches events). Same octane adaptations as Checkbox.ts:
6
+ // `isPropagationStopped()` → `event.cancelBubble`; the source's uncontrolled input +
7
+ // descriptor `checked` writes octane's controlled `checked`; a native `change`
8
+ // dispatched alongside the source's `click`.
8
9
  import { createElement, useEffect, useRef, useState } from 'octane';
9
10
 
10
11
  import { composeEventHandlers } from './compose-event-handlers';
@@ -193,7 +194,6 @@ export function BubbleInput(props: any): any {
193
194
  control,
194
195
  hasConsumerStoppedPropagationRef,
195
196
  checked,
196
- defaultChecked,
197
197
  required,
198
198
  disabled,
199
199
  name,
@@ -207,22 +207,16 @@ export function BubbleInput(props: any): any {
207
207
  const prevChecked = usePrevious(checked, subSlot(slot, 'prev'));
208
208
  const controlSize = useSize(control, subSlot(slot, 'size'));
209
209
 
210
- // Bubble checked change to parents (e.g form change event)
210
+ // Bubble checked change to parents (e.g form change event). The controlled
211
+ // `checked` prop below keeps the input's DOM state in sync, so this effect
212
+ // only dispatches the events (see Checkbox.ts).
211
213
  useEffect(
212
214
  () => {
213
215
  const input = bubbleInput;
214
216
  if (!input) return;
215
217
 
216
- const inputProto = window.HTMLInputElement.prototype;
217
- const descriptor = Object.getOwnPropertyDescriptor(
218
- inputProto,
219
- 'checked',
220
- ) as PropertyDescriptor;
221
- const setChecked = descriptor.set;
222
-
223
218
  const bubbles = !hasConsumerStoppedPropagationRef.current;
224
- if (prevChecked !== checked && setChecked) {
225
- setChecked.call(input, checked);
219
+ if (prevChecked !== checked) {
226
220
  input.dispatchEvent(new Event('click', { bubbles }));
227
221
  // octane adaptation: also fire the native `change` (see Checkbox.ts header).
228
222
  input.dispatchEvent(new Event('change', { bubbles }));
@@ -232,12 +226,11 @@ export function BubbleInput(props: any): any {
232
226
  subSlot(slot, 'e:bubble'),
233
227
  );
234
228
 
235
- const defaultCheckedRef = useRef(checked, subSlot(slot, 'default'));
236
229
  return createElement(Primitive.input, {
237
230
  type: 'checkbox',
238
231
  'aria-hidden': true,
239
- // octane: native `checked` attribute = default-checked state (see Checkbox.ts).
240
- checked: (defaultChecked ?? defaultCheckedRef.current) || undefined,
232
+ // Live CONTROLLED checked (octane React-parity see Checkbox.ts).
233
+ checked,
241
234
  required,
242
235
  disabled,
243
236
  name,