@octanejs/radix 0.1.2

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 (62) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +100 -0
  3. package/package.json +45 -0
  4. package/src/AccessibleIcon.ts +26 -0
  5. package/src/Accordion.ts +282 -0
  6. package/src/AlertDialog.ts +110 -0
  7. package/src/Arrow.ts +21 -0
  8. package/src/AspectRatio.ts +34 -0
  9. package/src/Avatar.ts +152 -0
  10. package/src/Checkbox.ts +325 -0
  11. package/src/Collapsible.ts +170 -0
  12. package/src/ContextMenu.ts +303 -0
  13. package/src/Dialog.ts +308 -0
  14. package/src/DismissableLayer.ts +413 -0
  15. package/src/DropdownMenu.ts +275 -0
  16. package/src/FocusScope.ts +266 -0
  17. package/src/Form.ts +621 -0
  18. package/src/HoverCard.ts +318 -0
  19. package/src/Label.ts +25 -0
  20. package/src/Menu.ts +1057 -0
  21. package/src/Menubar.ts +572 -0
  22. package/src/NavigationMenu.ts +1236 -0
  23. package/src/OneTimePasswordField.ts +977 -0
  24. package/src/PasswordToggleField.ts +495 -0
  25. package/src/Popover.ts +354 -0
  26. package/src/Popper.ts +401 -0
  27. package/src/Portal.ts +19 -0
  28. package/src/Presence.ts +225 -0
  29. package/src/Primitive.ts +53 -0
  30. package/src/Progress.ts +92 -0
  31. package/src/Radio.ts +262 -0
  32. package/src/RadioGroup.ts +212 -0
  33. package/src/RovingFocusGroup.ts +259 -0
  34. package/src/ScrollArea.ts +966 -0
  35. package/src/Select.ts +1901 -0
  36. package/src/Separator.ts +36 -0
  37. package/src/Slider.ts +745 -0
  38. package/src/Slot.ts +105 -0
  39. package/src/Switch.ts +278 -0
  40. package/src/Tabs.ts +177 -0
  41. package/src/Toast.ts +923 -0
  42. package/src/Toggle.ts +31 -0
  43. package/src/ToggleGroup.ts +157 -0
  44. package/src/Toolbar.ts +130 -0
  45. package/src/Tooltip.ts +669 -0
  46. package/src/VisuallyHidden.ts +29 -0
  47. package/src/collection.ts +97 -0
  48. package/src/compose-event-handlers.ts +15 -0
  49. package/src/compose-refs.ts +53 -0
  50. package/src/context.ts +109 -0
  51. package/src/direction.ts +19 -0
  52. package/src/focus-guards.ts +56 -0
  53. package/src/index.ts +54 -0
  54. package/src/internal.ts +42 -0
  55. package/src/scroll-lock.ts +57 -0
  56. package/src/use-callback-ref.ts +28 -0
  57. package/src/use-effect-event.ts +36 -0
  58. package/src/use-is-hydrated.ts +23 -0
  59. package/src/use-previous.ts +28 -0
  60. package/src/use-size.ts +57 -0
  61. package/src/useControllableState.ts +78 -0
  62. package/src/useId.ts +15 -0
@@ -0,0 +1,97 @@
1
+ // Ported from @radix-ui/react-collection (the "legacy" createCollection the shipped
2
+ // primitives use — Accordion, Toolbar, RovingFocusGroup, …). A Provider owns an item map;
3
+ // `Collection.Slot` marks the collection root element (via ref); `Collection.ItemSlot`
4
+ // stamps `data-radix-collection-item` on its child + registers it; `useCollection`
5
+ // returns the registered items sorted by DOM order. React's forwardRef/createSlot →
6
+ // octane ref-as-prop + our `Slot`.
7
+ import { createElement, useEffect, useRef } from 'octane';
8
+
9
+ import { useComposedRefs } from './compose-refs';
10
+ import { createContextScope } from './context';
11
+ import { S, splitSlot, subSlot } from './internal';
12
+ import { Slot } from './Slot';
13
+
14
+ const ITEM_DATA_ATTR = 'data-radix-collection-item';
15
+
16
+ interface CollectionContextValue {
17
+ collectionRef: { current: HTMLElement | null };
18
+ itemMap: Map<{ current: HTMLElement | null }, { ref: { current: HTMLElement | null } }>;
19
+ }
20
+
21
+ export function createCollection(name: string): [
22
+ {
23
+ Provider: (props: any) => any;
24
+ Slot: (props: any) => any;
25
+ ItemSlot: (props: any) => any;
26
+ },
27
+ (scope: any, ...slot: any[]) => () => any[],
28
+ any,
29
+ ] {
30
+ const PROVIDER_NAME = name + 'CollectionProvider';
31
+ const [createCollectionContext, createCollectionScope] = createContextScope(PROVIDER_NAME);
32
+ const [CollectionProviderImpl, useCollectionContext] =
33
+ createCollectionContext<CollectionContextValue>(PROVIDER_NAME, {
34
+ collectionRef: { current: null },
35
+ itemMap: new Map(),
36
+ });
37
+
38
+ function CollectionProvider(props: any): any {
39
+ const slot = S(PROVIDER_NAME);
40
+ const { scope, children } = props;
41
+ const ref = useRef<HTMLElement | null>(null, subSlot(slot, 'ref'));
42
+ const itemMap = useRef(new Map(), subSlot(slot, 'map')).current;
43
+ return createElement(CollectionProviderImpl, { scope, itemMap, collectionRef: ref, children });
44
+ }
45
+
46
+ function CollectionSlot(props: any): any {
47
+ const slot = S(name + 'CollectionSlot');
48
+ const { scope, children, ref: forwardedRef } = props;
49
+ const context = useCollectionContext(name + 'CollectionSlot', scope);
50
+ const composedRefs = useComposedRefs(
51
+ forwardedRef,
52
+ context.collectionRef,
53
+ subSlot(slot, 'refs'),
54
+ );
55
+ return createElement(Slot, { ref: composedRefs, children });
56
+ }
57
+
58
+ function CollectionItemSlot(props: any): any {
59
+ const slot = S(name + 'CollectionItemSlot');
60
+ const { scope, children, ref: forwardedRef, ...itemData } = props;
61
+ const ref = useRef<HTMLElement | null>(null, subSlot(slot, 'ref'));
62
+ const composedRefs = useComposedRefs(forwardedRef, ref, subSlot(slot, 'refs'));
63
+ const context = useCollectionContext(name + 'CollectionItemSlot', scope);
64
+ // No deps (React parity): re-register every render so itemData stays fresh.
65
+ useEffect(
66
+ () => {
67
+ context.itemMap.set(ref, { ref, ...itemData });
68
+ return () => void context.itemMap.delete(ref);
69
+ },
70
+ undefined,
71
+ subSlot(slot, 'e:reg'),
72
+ );
73
+ return createElement(Slot, { [ITEM_DATA_ATTR]: '', ref: composedRefs, children });
74
+ }
75
+
76
+ function useCollection(...args: any[]): () => any[] {
77
+ const [user, slotArg] = splitSlot(args);
78
+ const scope = user[0];
79
+ void slotArg;
80
+ const context = useCollectionContext(name + 'CollectionConsumer', scope);
81
+ return () => {
82
+ const collectionNode = context.collectionRef.current;
83
+ if (!collectionNode) return [];
84
+ const orderedNodes = Array.from(collectionNode.querySelectorAll(`[${ITEM_DATA_ATTR}]`));
85
+ const items = Array.from(context.itemMap.values());
86
+ return items.sort(
87
+ (a, b) => orderedNodes.indexOf(a.ref.current!) - orderedNodes.indexOf(b.ref.current!),
88
+ );
89
+ };
90
+ }
91
+
92
+ return [
93
+ { Provider: CollectionProvider, Slot: CollectionSlot, ItemSlot: CollectionItemSlot },
94
+ useCollection,
95
+ createCollectionScope,
96
+ ];
97
+ }
@@ -0,0 +1,15 @@
1
+ // Ported from @radix-ui/primitive. Chains an original (user) handler with our behavior
2
+ // handler, skipping ours if the user called preventDefault. octane events are native, so
3
+ // `event.defaultPrevented` is the real DOM flag.
4
+ export function composeEventHandlers<E extends { defaultPrevented: boolean }>(
5
+ originalEventHandler?: (event: E) => void,
6
+ ourEventHandler?: (event: E) => void,
7
+ { checkForDefaultPrevented = true } = {},
8
+ ): (event: E) => void {
9
+ return function handleEvent(event: E) {
10
+ originalEventHandler?.(event);
11
+ if (checkForDefaultPrevented === false || !event.defaultPrevented) {
12
+ return ourEventHandler?.(event);
13
+ }
14
+ };
15
+ }
@@ -0,0 +1,53 @@
1
+ // Ported from @radix-ui/react-compose-refs. Merges multiple refs (objects or callbacks,
2
+ // incl. React-19 cleanup-returning callback refs) into one callback ref. octane is
3
+ // ref-as-prop, so these compose the `ref` values a Slot/Primitive threads onto a child.
4
+ import { useCallback } from 'octane';
5
+
6
+ import { S, splitSlot, subSlot } from './internal';
7
+
8
+ type PossibleRef<T> = ((instance: T | null) => void | (() => void)) | { current: T | null } | null;
9
+
10
+ function setRef<T>(ref: PossibleRef<T>, value: T | null): void | (() => void) {
11
+ if (typeof ref === 'function') {
12
+ return ref(value);
13
+ } else if (ref !== null && ref !== undefined) {
14
+ ref.current = value;
15
+ }
16
+ }
17
+
18
+ /**
19
+ * Compose multiple refs into a single callback ref. Honors cleanup functions returned by
20
+ * callback refs (React 19 semantics); falls back to calling refs with `null` on unmount.
21
+ */
22
+ export function composeRefs<T>(...refs: PossibleRef<T>[]): (node: T | null) => void | (() => void) {
23
+ return (node: T | null) => {
24
+ let hasCleanup = false;
25
+ const cleanups = refs.map((ref) => {
26
+ const cleanup = setRef(ref, node);
27
+ if (!hasCleanup && typeof cleanup === 'function') {
28
+ hasCleanup = true;
29
+ }
30
+ return cleanup;
31
+ });
32
+ if (hasCleanup) {
33
+ return () => {
34
+ for (let i = 0; i < cleanups.length; i++) {
35
+ const cleanup = cleanups[i];
36
+ if (typeof cleanup === 'function') {
37
+ cleanup();
38
+ } else {
39
+ setRef(refs[i], null);
40
+ }
41
+ }
42
+ };
43
+ }
44
+ };
45
+ }
46
+
47
+ /** Hook version — a stable composed callback ref over the given refs. */
48
+ export function useComposedRefs<T>(...args: any[]): (node: T | null) => void | (() => void) {
49
+ const [user, slotArg] = splitSlot(args);
50
+ const slot = slotArg ?? S('useComposedRefs');
51
+ const refs = user as PossibleRef<T>[];
52
+ return useCallback(composeRefs(...refs), refs, subSlot(slot, 'cb'));
53
+ }
package/src/context.ts ADDED
@@ -0,0 +1,109 @@
1
+ // Ported from @radix-ui/react-context (createContextScope). Scoped context factories so a
2
+ // primitive can be composed inside another (e.g. Accordion's internal Collapsible) without
3
+ // its context colliding with a user's separate instance of the same primitive. React's
4
+ // createContext → octane createContext; the `scope` indirection + scope composition are
5
+ // preserved. The hooks are slot-threaded for octane's plain-`.ts` consumption
6
+ // (S/splitSlot/subSlot) since these files skip the compiler's auto-slotting pass.
7
+ import {
8
+ createContext as octaneCreateContext,
9
+ createElement,
10
+ useContext as octaneUseContext,
11
+ useMemo,
12
+ } from 'octane';
13
+
14
+ import { S, splitSlot, subSlot } from './internal';
15
+
16
+ type Scope<C = any> = { [scopeName: string]: (C | undefined)[] } | undefined;
17
+ type ScopeHook = (...args: any[]) => any;
18
+ interface CreateScope {
19
+ scopeName: string;
20
+ (): ScopeHook;
21
+ }
22
+
23
+ export function createContextScope(
24
+ scopeName: string,
25
+ createContextScopeDeps: CreateScope[] = [],
26
+ ): [
27
+ <T>(
28
+ rootComponentName: string,
29
+ defaultContext?: T,
30
+ ) => [(props: any) => any, (consumerName: string, scope?: Scope, ...slot: any[]) => T],
31
+ CreateScope,
32
+ ] {
33
+ let defaultContexts: any[] = [];
34
+
35
+ function createContext<T>(
36
+ rootComponentName: string,
37
+ defaultContext?: T,
38
+ ): [(props: any) => any, (consumerName: string, scope?: Scope, ...slot: any[]) => T] {
39
+ const BaseContext = octaneCreateContext(defaultContext);
40
+ const index = defaultContexts.length;
41
+ defaultContexts = [...defaultContexts, defaultContext];
42
+
43
+ function Provider(props: any): any {
44
+ const { scope, children, ...context } = props;
45
+ const Context = scope?.[scopeName]?.[index] || BaseContext;
46
+ const value = useMemo(() => context, Object.values(context), S(scopeName + ':P' + index));
47
+ return createElement(Context.Provider, { value, children });
48
+ }
49
+
50
+ function useContext(consumerName: string, scope?: Scope): T {
51
+ const Context = scope?.[scopeName]?.[index] || BaseContext;
52
+ const context = octaneUseContext(Context);
53
+ if (context) return context as T;
54
+ if (defaultContext !== undefined) return defaultContext;
55
+ throw new Error(`\`${consumerName}\` must be used within \`${rootComponentName}\``);
56
+ }
57
+
58
+ return [Provider, useContext];
59
+ }
60
+
61
+ const createScope: CreateScope = (() => {
62
+ const scopeContexts = defaultContexts.map((defaultContext) =>
63
+ octaneCreateContext(defaultContext),
64
+ );
65
+ return function useScope(...args: any[]): Record<string, Scope> {
66
+ const [user, slotArg] = splitSlot(args);
67
+ const slot = slotArg ?? S(scopeName + ':useScope');
68
+ const scope = user[0] as Scope;
69
+ const contexts = scope?.[scopeName] || scopeContexts;
70
+ return useMemo(
71
+ () => ({ [`__scope${scopeName}`]: { ...scope, [scopeName]: contexts } }),
72
+ [scope, contexts],
73
+ subSlot(slot, 'm'),
74
+ );
75
+ };
76
+ }) as CreateScope;
77
+ createScope.scopeName = scopeName;
78
+
79
+ return [createContext, composeContextScopes(createScope, ...createContextScopeDeps)];
80
+ }
81
+
82
+ function composeContextScopes(...scopes: CreateScope[]): CreateScope {
83
+ const baseScope = scopes[0];
84
+ if (scopes.length === 1) return baseScope;
85
+
86
+ const createScope: CreateScope = (() => {
87
+ const scopeHooks = scopes.map((createScope2) => ({
88
+ useScope: createScope2(),
89
+ scopeName: createScope2.scopeName,
90
+ }));
91
+ return function useComposedScopes(...args: any[]): any {
92
+ const [user, slotArg] = splitSlot(args);
93
+ const slot = slotArg ?? S('composed:' + baseScope.scopeName);
94
+ const overrideScopes = user[0] as Scope;
95
+ const nextScopes = scopeHooks.reduce((acc: any, { useScope, scopeName }, i) => {
96
+ const scopeProps = useScope(overrideScopes, subSlot(slot, 's' + i));
97
+ const currentScope = scopeProps[`__scope${scopeName}`];
98
+ return { ...acc, ...currentScope };
99
+ }, {} as any);
100
+ return useMemo(
101
+ () => ({ [`__scope${baseScope.scopeName}`]: nextScopes }),
102
+ [nextScopes],
103
+ subSlot(slot, 'm'),
104
+ );
105
+ };
106
+ }) as CreateScope;
107
+ createScope.scopeName = baseScope.scopeName;
108
+ return createScope;
109
+ }
@@ -0,0 +1,19 @@
1
+ // Ported from @radix-ui/react-direction (source:
2
+ // .radix-primitives/packages/react/direction/src/direction.tsx). Global reading-direction
3
+ // context with a per-call local override.
4
+ import { createContext, createElement, useContext } from 'octane';
5
+
6
+ type Direction = 'ltr' | 'rtl';
7
+ const DirectionContext = createContext<Direction | undefined>(undefined);
8
+
9
+ export function DirectionProvider(props: { dir: Direction; children?: any }): any {
10
+ const { dir, children } = props;
11
+ return createElement(DirectionContext.Provider, { value: dir, children });
12
+ }
13
+
14
+ export function useDirection(localDir?: Direction): Direction {
15
+ const globalDir = useContext(DirectionContext);
16
+ return localDir || globalDir || 'ltr';
17
+ }
18
+
19
+ export { DirectionProvider as Provider };
@@ -0,0 +1,56 @@
1
+ // Ported from @radix-ui/react-focus-guards. Injects a pair of focusable, visually-hidden
2
+ // guard spans at the edges of `document.body` so that tabbing out of a focus-trapped
3
+ // layer always has somewhere to land (Safari/VoiceOver edge cases). Ref-counted across
4
+ // consumers; the spans carry `data-radix-focus-guard`.
5
+ import { useEffect } from 'octane';
6
+
7
+ import { S, splitSlot, subSlot } from './internal';
8
+
9
+ let count = 0;
10
+ let guards: { start: HTMLElement; end: HTMLElement } | null = null;
11
+
12
+ export function useFocusGuards(...args: any[]): void {
13
+ const [, slotArg] = splitSlot(args);
14
+ const slot = slotArg ?? S('useFocusGuards');
15
+ useEffect(
16
+ () => {
17
+ if (!guards) {
18
+ guards = { start: createFocusGuard(), end: createFocusGuard() };
19
+ }
20
+ const { start, end } = guards;
21
+ if (document.body.firstElementChild !== start) {
22
+ document.body.insertAdjacentElement('afterbegin', start);
23
+ }
24
+ if (document.body.lastElementChild !== end) {
25
+ document.body.insertAdjacentElement('beforeend', end);
26
+ }
27
+ count++;
28
+ return () => {
29
+ if (count === 1) {
30
+ guards?.start.remove();
31
+ guards?.end.remove();
32
+ guards = null;
33
+ }
34
+ count = Math.max(0, count - 1);
35
+ };
36
+ },
37
+ [],
38
+ subSlot(slot, 'e'),
39
+ );
40
+ }
41
+
42
+ export function FocusGuards(props: any): any {
43
+ useFocusGuards(S('FocusGuards'));
44
+ return props.children;
45
+ }
46
+
47
+ function createFocusGuard(): HTMLElement {
48
+ const element = document.createElement('span');
49
+ element.setAttribute('data-radix-focus-guard', '');
50
+ element.tabIndex = 0;
51
+ element.style.outline = 'none';
52
+ element.style.opacity = '0';
53
+ element.style.position = 'fixed';
54
+ element.style.pointerEvents = 'none';
55
+ return element;
56
+ }
package/src/index.ts ADDED
@@ -0,0 +1,54 @@
1
+ // @octanejs/radix — a port of Radix UI Primitives (@radix-ui/react) on the octane
2
+ // renderer. Mirrors the unified `radix-ui` package's shape: low-level composition
3
+ // utilities are exported directly, and each component is a namespace (`Separator.Root`).
4
+ //
5
+ // Phase 0 (foundation) + first proof components. See docs/radix-migration-plan.md.
6
+
7
+ // Composition foundation.
8
+ export { Slot, Slottable } from './Slot';
9
+ export { Primitive } from './Primitive';
10
+ export { composeRefs, useComposedRefs } from './compose-refs';
11
+ export { composeEventHandlers } from './compose-event-handlers';
12
+ export { createContextScope } from './context';
13
+ export { useControllableState } from './useControllableState';
14
+ export { Presence } from './Presence';
15
+
16
+ // Components (namespaced, e.g. `<Separator.Root/>`, `<Accordion.Root/>`).
17
+ export * as Separator from './Separator';
18
+ export * as Label from './Label';
19
+ export * as Collapsible from './Collapsible';
20
+ export * as Accordion from './Accordion';
21
+ export * as Portal from './Portal';
22
+ export * as Dialog from './Dialog';
23
+ export * as AlertDialog from './AlertDialog';
24
+ export * as Toggle from './Toggle';
25
+ export * as ToggleGroup from './ToggleGroup';
26
+ export * as Tabs from './Tabs';
27
+ export * as RovingFocus from './RovingFocusGroup';
28
+ export * as AspectRatio from './AspectRatio';
29
+ export * as VisuallyHidden from './VisuallyHidden';
30
+ export * as Avatar from './Avatar';
31
+ export * as Progress from './Progress';
32
+ export * as Toolbar from './Toolbar';
33
+ export * as Arrow from './Arrow';
34
+ export * as Popper from './Popper';
35
+ export * as Tooltip from './Tooltip';
36
+ export * as Popover from './Popover';
37
+ export * as HoverCard from './HoverCard';
38
+ export * as Menu from './Menu';
39
+ export * as DropdownMenu from './DropdownMenu';
40
+ export * as ContextMenu from './ContextMenu';
41
+ export * as ScrollArea from './ScrollArea';
42
+ export * as Checkbox from './Checkbox';
43
+ export * as Switch from './Switch';
44
+ export * as RadioGroup from './RadioGroup';
45
+ export * as Slider from './Slider';
46
+ export * as Form from './Form';
47
+ export * as Menubar from './Menubar';
48
+ export * as Select from './Select';
49
+ export * as NavigationMenu from './NavigationMenu';
50
+ export * as Toast from './Toast';
51
+ export * as OneTimePasswordField from './OneTimePasswordField';
52
+ export * as PasswordToggleField from './PasswordToggleField';
53
+ export * as AccessibleIcon from './AccessibleIcon';
54
+ export * as Direction from './direction';
@@ -0,0 +1,42 @@
1
+ // Slot mechanics for @octanejs/radix's plain-`.ts` hooks. octane's compiler injects a
2
+ // per-call-site Symbol slot as the trailing arg of every hook call in a compiled
3
+ // `.tsx`/`.tsrx`. These hook files are published source (consumed from node_modules,
4
+ // where the auto-slotting pass is skipped), so each hook receives the caller's slot as
5
+ // its trailing argument and derives a distinct sub-slot for every base hook it composes.
6
+ // (Same pattern as @octanejs/floating-ui and the other bindings.)
7
+
8
+ // Derive a stable, distinct sub-slot from a wrapper's slot, namespaced per hook.
9
+ // Memoized: subSlot runs on EVERY hook call every render, and the naive form
10
+ // pays a string concat + global symbol-registry lookup each time. The cache is
11
+ // keyed by the slot symbol itself; the minted value is byte-identical to the
12
+ // uncached Symbol.for result, so identity is preserved across HMR re-evals and
13
+ // the per-package copies of this helper. Key universe is bounded: slots are
14
+ // per-call-site module constants (never minted per render).
15
+ const subSlotCache = new Map<symbol, Map<string, symbol>>();
16
+ export function subSlot(slot: symbol | undefined, tag: string): symbol | undefined {
17
+ if (slot === undefined) return undefined;
18
+ let byTag = subSlotCache.get(slot);
19
+ if (byTag === undefined) subSlotCache.set(slot, (byTag = new Map()));
20
+ let sym = byTag.get(tag);
21
+ if (sym === undefined) byTag.set(tag, (sym = Symbol.for((slot.description ?? '') + ':' + tag)));
22
+ return sym;
23
+ }
24
+
25
+ // Split the compiler-injected trailing slot off a hook's args. Needed because the public
26
+ // hooks take optional args, so the slot can't be located positionally.
27
+ export function splitSlot(args: any[]): [any[], symbol | undefined] {
28
+ const tail = args[args.length - 1];
29
+ const slot = typeof tail === 'symbol' ? (tail as symbol) : undefined;
30
+ return [slot !== undefined ? args.slice(0, -1) : args, slot];
31
+ }
32
+
33
+ // A stable per-call-site slot for the binding's plain-`.ts` COMPONENTS (written with
34
+ // createElement, not compiled, so they get no auto-injected slots). A component runs in
35
+ // its OWN per-instance scope (componentSlot), so a globally stable Symbol.for(tag)
36
+ // resolves to a distinct slot per instance.
37
+ const sCache = new Map<string, symbol>();
38
+ export function S(tag: string): symbol {
39
+ let sym = sCache.get(tag);
40
+ if (sym === undefined) sCache.set(tag, (sym = Symbol.for('@octanejs/radix:' + tag)));
41
+ return sym;
42
+ }
@@ -0,0 +1,57 @@
1
+ // Body scroll lock for modal overlays — the octane replacement for the `react-remove-scroll`
2
+ // component Radix's Dialog wraps its overlay in (that library is React-coupled: a component
3
+ // + use-sidecar machinery). This util reproduces the observable core — `overflow: hidden`
4
+ // on <body> with scrollbar-width padding compensation, ref-counted across overlapping
5
+ // locks — as a hook the overlay calls.
6
+ //
7
+ // DIVERGENCE (documented): react-remove-scroll additionally intercepts wheel/touchmove
8
+ // events to allow scrolling inside `shards` while the body is locked, and supports
9
+ // pinch-zoom allowances. Those event-level behaviors are not replicated yet; scrollable
10
+ // dialog CONTENT still works because the content element itself scrolls (overflow on the
11
+ // content), which is the common Radix usage.
12
+ import { useEffect } from 'octane';
13
+
14
+ import { S, splitSlot, subSlot } from './internal';
15
+
16
+ let lockCount = 0;
17
+ let originalOverflow = '';
18
+ let originalPaddingRight = '';
19
+
20
+ function lockBody(): void {
21
+ if (lockCount === 0) {
22
+ const body = document.body;
23
+ originalOverflow = body.style.overflow;
24
+ originalPaddingRight = body.style.paddingRight;
25
+ const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
26
+ if (scrollbarWidth > 0) {
27
+ const computedPadding = parseInt(getComputedStyle(body).paddingRight, 10) || 0;
28
+ body.style.paddingRight = `${computedPadding + scrollbarWidth}px`;
29
+ }
30
+ body.style.overflow = 'hidden';
31
+ }
32
+ lockCount++;
33
+ }
34
+
35
+ function unlockBody(): void {
36
+ lockCount = Math.max(0, lockCount - 1);
37
+ if (lockCount === 0) {
38
+ document.body.style.overflow = originalOverflow;
39
+ document.body.style.paddingRight = originalPaddingRight;
40
+ }
41
+ }
42
+
43
+ /** Lock body scroll while `enabled` (ref-counted across simultaneous consumers). */
44
+ export function useScrollLock(...args: any[]): void {
45
+ const [user, slotArg] = splitSlot(args);
46
+ const slot = slotArg ?? S('useScrollLock');
47
+ const enabled = (user[0] as boolean | undefined) ?? true;
48
+ useEffect(
49
+ () => {
50
+ if (!enabled) return;
51
+ lockBody();
52
+ return unlockBody;
53
+ },
54
+ [enabled],
55
+ subSlot(slot, 'e'),
56
+ );
57
+ }
@@ -0,0 +1,28 @@
1
+ // Ported from @radix-ui/react-use-callback-ref (source:
2
+ // .radix-primitives/packages/react/use-callback-ref/src/use-callback-ref.tsx).
3
+ // Converts a callback to a stable function reading the latest value — avoids
4
+ // re-renders when passed as a prop and re-executing effects when a dependency.
5
+ import { useEffect, useMemo, useRef } from 'octane';
6
+
7
+ import { S, splitSlot, subSlot } from './internal';
8
+
9
+ export function useCallbackRef<T extends (...args: any[]) => any>(...args: any[]): T {
10
+ const [user, slotArg] = splitSlot(args);
11
+ const slot = slotArg ?? S('useCallbackRef');
12
+ const callback = user[0] as T | undefined;
13
+ const callbackRef = useRef(callback, subSlot(slot, 'ref'));
14
+
15
+ useEffect(
16
+ () => {
17
+ callbackRef.current = callback;
18
+ },
19
+ undefined,
20
+ subSlot(slot, 'e'),
21
+ );
22
+
23
+ return useMemo(
24
+ () => ((...fnArgs: any[]) => callbackRef.current?.(...fnArgs)) as T,
25
+ [],
26
+ subSlot(slot, 'm'),
27
+ );
28
+ }
@@ -0,0 +1,36 @@
1
+ // Ported from @radix-ui/react-use-effect-event (source:
2
+ // .radix-primitives/packages/react/use-effect-event/src/use-effect-event.tsx). A stable
3
+ // function whose body always reads the latest render's callback — the
4
+ // `experimental_useEffectEvent` approximation. The ref updates in an insertion effect
5
+ // (before any layout/passive effect can call it), and calling during render throws.
6
+ import { useInsertionEffect, useMemo, useRef } from 'octane';
7
+
8
+ import { S, splitSlot, subSlot } from './internal';
9
+
10
+ type AnyFunction = (...args: any[]) => any;
11
+
12
+ export function useEffectEvent<T extends AnyFunction>(...args: any[]): T {
13
+ const [user, slotArg] = splitSlot(args);
14
+ const slot = slotArg ?? S('useEffectEvent');
15
+ const callback = user[0] as T | undefined;
16
+
17
+ const ref = useRef<AnyFunction | undefined>(
18
+ () => {
19
+ throw new Error('Cannot call an event handler while rendering.');
20
+ },
21
+ subSlot(slot, 'ref'),
22
+ );
23
+ useInsertionEffect(
24
+ () => {
25
+ ref.current = callback;
26
+ },
27
+ undefined,
28
+ subSlot(slot, 'e'),
29
+ );
30
+
31
+ return useMemo(
32
+ () => ((...fnArgs: any[]) => ref.current?.(...fnArgs)) as T,
33
+ [],
34
+ subSlot(slot, 'm'),
35
+ );
36
+ }
@@ -0,0 +1,23 @@
1
+ // Ported from @radix-ui/react-use-is-hydrated (source:
2
+ // .radix-primitives/packages/react/use-is-hydrated/src/use-is-hydrated.tsx). Whether
3
+ // the component tree has hydrated: `useSyncExternalStore` returns the server snapshot
4
+ // (false) during SSR/hydration and the client snapshot (true) after. octane exposes
5
+ // useSyncExternalStore directly, so the source's legacy fallback isn't needed.
6
+ import { useSyncExternalStore } from 'octane';
7
+
8
+ import { S, splitSlot, subSlot } from './internal';
9
+
10
+ function subscribe(): () => void {
11
+ return () => {};
12
+ }
13
+
14
+ export function useIsHydrated(...args: any[]): boolean {
15
+ const [, slotArg] = splitSlot(args);
16
+ const slot = slotArg ?? S('useIsHydrated');
17
+ return useSyncExternalStore(
18
+ subscribe,
19
+ () => true,
20
+ () => false,
21
+ subSlot(slot, 's'),
22
+ );
23
+ }
@@ -0,0 +1,28 @@
1
+ // Ported from @radix-ui/react-use-previous (source:
2
+ // .radix-primitives/packages/react/use-previous/src/use-previous.tsx). Returns the
3
+ // value from the previous render in which `value` differed from the current one.
4
+ import { useMemo, useRef } from 'octane';
5
+
6
+ import { S, splitSlot, subSlot } from './internal';
7
+
8
+ export function usePrevious<T>(...args: any[]): T {
9
+ const [user, slotArg] = splitSlot(args);
10
+ const slot = slotArg ?? S('usePrevious');
11
+ const value = user[0] as T;
12
+ const ref = useRef({ value, previous: value }, subSlot(slot, 'ref'));
13
+
14
+ // We compare values before making an update to ensure that
15
+ // a change has been made. This ensures the previous value is
16
+ // persisted correctly between renders.
17
+ return useMemo(
18
+ () => {
19
+ if (ref.current.value !== value) {
20
+ ref.current.previous = ref.current.value;
21
+ ref.current.value = value;
22
+ }
23
+ return ref.current.previous;
24
+ },
25
+ [value],
26
+ subSlot(slot, 'm'),
27
+ );
28
+ }