@octanejs/base-ui 0.1.1 → 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 (67) hide show
  1. package/README.md +7 -1
  2. package/package.json +12 -4
  3. package/src/alert-dialog.ts +47 -0
  4. package/src/checkbox.ts +7 -16
  5. package/src/dialog.ts +859 -0
  6. package/src/field.ts +4 -30
  7. package/src/index.ts +3 -0
  8. package/src/number-field.ts +8 -45
  9. package/src/popover.ts +1205 -0
  10. package/src/radio.ts +3 -24
  11. package/src/slider.ts +4 -5
  12. package/src/switch.ts +4 -30
  13. package/src/utils/InternalBackdrop.ts +28 -0
  14. package/src/utils/adaptiveOriginMiddleware.ts +82 -0
  15. package/src/utils/closePart.ts +61 -0
  16. package/src/utils/constants.ts +9 -0
  17. package/src/utils/createChangeEventDetails.ts +7 -0
  18. package/src/utils/dom.ts +9 -0
  19. package/src/utils/empty.ts +7 -0
  20. package/src/utils/floating/FloatingFocusManager.ts +833 -0
  21. package/src/utils/floating/FloatingPortal.ts +268 -0
  22. package/src/utils/floating/FloatingRootStore.ts +127 -0
  23. package/src/utils/floating/FloatingTree.ts +70 -0
  24. package/src/utils/floating/FloatingTreeStore.ts +22 -0
  25. package/src/utils/floating/FocusGuard.ts +34 -0
  26. package/src/utils/floating/composite.ts +20 -0
  27. package/src/utils/floating/constants.ts +8 -0
  28. package/src/utils/floating/createAttribute.ts +4 -0
  29. package/src/utils/floating/createEventEmitter.ts +20 -0
  30. package/src/utils/floating/element.ts +54 -0
  31. package/src/utils/floating/enqueueFocus.ts +38 -0
  32. package/src/utils/floating/event.ts +53 -0
  33. package/src/utils/floating/getEmptyRootContext.ts +19 -0
  34. package/src/utils/floating/markOthers.ts +197 -0
  35. package/src/utils/floating/nodes.ts +31 -0
  36. package/src/utils/floating/tabbable.ts +260 -0
  37. package/src/utils/floating/types.ts +57 -0
  38. package/src/utils/floating/useClick.ts +174 -0
  39. package/src/utils/floating/useDismiss.ts +641 -0
  40. package/src/utils/floating/useFloating.ts +198 -0
  41. package/src/utils/floating/useFloatingRootContext.ts +81 -0
  42. package/src/utils/floating/useHoverFloatingInteraction.ts +12 -0
  43. package/src/utils/floating/useHoverReferenceInteraction.ts +17 -0
  44. package/src/utils/floating/useSyncedFloatingRootContext.ts +93 -0
  45. package/src/utils/getDisabledMountTransitionStyles.ts +12 -0
  46. package/src/utils/hideMiddleware.ts +22 -0
  47. package/src/utils/inertValue.ts +5 -0
  48. package/src/utils/mergeCleanups.ts +13 -0
  49. package/src/utils/platform.ts +46 -2
  50. package/src/utils/popupStateMapping.ts +48 -0
  51. package/src/utils/popups/index.ts +4 -0
  52. package/src/utils/popups/popupStoreUtils.ts +484 -0
  53. package/src/utils/popups/popupTriggerMap.ts +62 -0
  54. package/src/utils/popups/store.ts +147 -0
  55. package/src/utils/popups/useTriggerFocusGuards.ts +73 -0
  56. package/src/utils/store/ReactStore.ts +168 -0
  57. package/src/utils/store/Store.ts +84 -0
  58. package/src/utils/store/createSelector.ts +66 -0
  59. package/src/utils/store/useStore.ts +49 -0
  60. package/src/utils/useAnchorPositioning.ts +581 -0
  61. package/src/utils/useAnchoredPopupScrollLock.ts +50 -0
  62. package/src/utils/useAnimationFrame.ts +28 -5
  63. package/src/utils/useEnhancedClickHandler.ts +47 -0
  64. package/src/utils/useOnFirstRender.ts +11 -0
  65. package/src/utils/useOpenInteractionType.ts +32 -0
  66. package/src/utils/usePositioner.ts +48 -0
  67. package/src/utils/useScrollLock.ts +253 -0
package/src/radio.ts CHANGED
@@ -4,8 +4,8 @@
4
4
  //
5
5
  // A radio renders a `<span role="radio">` + a hidden `<input type="radio">` (a CompositeItem
6
6
  // for roving focus when inside a <RadioGroup>). octane adaptations mirror Switch/Checkbox:
7
- // native events; the uncontrolled-input pattern (initial checked attribute, live
8
- // `input.checked` PROPERTY via the native setter, native click/change dispatch).
7
+ // native events; the hidden input takes the live `checked` prop (octane inputs are CONTROLLED
8
+ // exactly like React's) with native click dispatch.
9
9
  import { createContext, createElement, useContext, useLayoutEffect, useMemo, useRef } from 'octane';
10
10
 
11
11
  import { S, subSlot } from './internal';
@@ -138,9 +138,6 @@ function RadioRoot(props: RadioRootProps): any {
138
138
  const radioRef = useRef<HTMLElement | null>(null, subSlot(slot, 'radioRef'));
139
139
  const inputRef = useRef<HTMLInputElement | null>(null, subSlot(slot, 'inputRef'));
140
140
 
141
- // octane: reflect the INITIAL checked as the `checked` ATTRIBUTE (see Switch).
142
- const initialCheckedRef = useRef(checked, subSlot(slot, 'initialChecked'));
143
-
144
141
  const handleControlRef = useStableCallback(
145
142
  (element: HTMLElement | null) => {
146
143
  if (!element) {
@@ -168,23 +165,6 @@ function RadioRoot(props: RadioRootProps): any {
168
165
  subSlot(slot, 'e:filled'),
169
166
  );
170
167
 
171
- // octane: drive `input.checked` PROPERTY imperatively (matches React's controlled input).
172
- useLayoutEffect(
173
- () => {
174
- const input = inputRef.current;
175
- if (!input) {
176
- return;
177
- }
178
- const setNativeChecked = Object.getOwnPropertyDescriptor(
179
- ownerWindow(input).HTMLInputElement.prototype,
180
- 'checked',
181
- )?.set;
182
- setNativeChecked?.call(input, checked);
183
- },
184
- [checked],
185
- subSlot(slot, 'e:syncChecked'),
186
- );
187
-
188
168
  useLayoutEffect(
189
169
  () => {
190
170
  if (!inputRef.current) {
@@ -275,8 +255,7 @@ function RadioRoot(props: RadioRootProps): any {
275
255
  'aria-hidden': true,
276
256
  ...(value !== undefined ? { value: serializeValue(value) } : {}),
277
257
  disabled,
278
- // octane: initial checked → attribute; live value via the property sync effect.
279
- checked: initialCheckedRef.current || undefined,
258
+ checked,
280
259
  required,
281
260
  readOnly,
282
261
  onChange(event: any) {
package/src/slider.ts CHANGED
@@ -1441,11 +1441,10 @@ function SliderThumb(componentProps: any): any {
1441
1441
  },
1442
1442
  tabIndex: tabIndexProp ?? undefined,
1443
1443
  type: 'range',
1444
- // octane: a controlled range input reflects its live value to the `value` ATTRIBUTE
1445
- // (verified vs React) octane's native attribute write matches, so no freeze/property
1446
- // adaptation is needed here (unlike a controlled TEXT input). Base UI prevents native
1447
- // range interaction (keydown → preventDefault), so the input stays pristine and the
1448
- // attribute drives the property/position.
1444
+ // octane: `value` is live CONTROLLED (React parity) the runtime drives the
1445
+ // `.value` property, mirrors it to the attribute, and reasserts after commits and
1446
+ // discrete events. Base UI prevents native range interaction (keydown →
1447
+ // preventDefault), so `thumbValue` stays the single source of truth, as on React.
1449
1448
  value: thumbValue ?? '',
1450
1449
  },
1451
1450
  (props: any) => validation.getValidationProps(disabled, props),
package/src/switch.ts CHANGED
@@ -4,10 +4,9 @@
4
4
  //
5
5
  // A switch renders a `<span role="switch">` plus a hidden `<input type="checkbox">`. octane
6
6
  // adaptations: (1) events are NATIVE — the handlers read the event directly (Base UI's
7
- // `event.nativeEvent`); (2) octane inputs are UNCONTROLLED (a `checked` prop would write a
8
- // `checked` ATTRIBUTE, which React's controlled input never does), so we DON'T pass `checked`
9
- // to the input and instead drive `input.checked` imperatively through the native property
10
- // setter in a layout effect — matching React's property-only controlled behavior. Field/Form
7
+ // `event.nativeEvent`); (2) the hidden input takes the live `checked` prop octane inputs
8
+ // are CONTROLLED exactly like React's (property-driven, reasserted on every commit and after
9
+ // discrete events; only the INITIAL checked reflects to the attribute). Field/Form
11
10
  // integration is inert when standalone (default contexts).
12
11
  import { createContext, createElement, useContext, useLayoutEffect, useMemo, useRef } from 'octane';
13
12
 
@@ -155,11 +154,6 @@ function SwitchRoot(props: SwitchRootProps): any {
155
154
  subSlot(slot, 'checked'),
156
155
  );
157
156
 
158
- // octane: React's controlled `<input checked>` reflects the INITIAL checked to the
159
- // `checked` ATTRIBUTE (its default-state), then updates only the property. Capture that
160
- // initial value so the attribute matches; the property is driven by the sync effect below.
161
- const initialCheckedRef = useRef(checked, subSlot(slot, 'initialChecked'));
162
-
163
157
  useRegisterFieldControl(
164
158
  switchRef,
165
159
  id,
@@ -180,24 +174,6 @@ function SwitchRoot(props: SwitchRootProps): any {
180
174
  subSlot(slot, 'e:filled'),
181
175
  );
182
176
 
183
- // octane uncontrolled-input adaptation: drive the hidden input's `checked` PROPERTY (never
184
- // the attribute) through the native setter, so the DOM matches React's controlled input.
185
- useLayoutEffect(
186
- () => {
187
- const input = inputRef.current;
188
- if (!input) {
189
- return;
190
- }
191
- const setNativeChecked = Object.getOwnPropertyDescriptor(
192
- ownerWindow(input).HTMLInputElement.prototype,
193
- 'checked',
194
- )?.set;
195
- setNativeChecked?.call(input, checked);
196
- },
197
- [checked],
198
- subSlot(slot, 'e:syncChecked'),
199
- );
200
-
201
177
  useValueChanged(
202
178
  checked,
203
179
  () => {
@@ -268,9 +244,7 @@ function SwitchRoot(props: SwitchRootProps): any {
268
244
 
269
245
  const inputProps: Record<string, any> = mergeProps(
270
246
  {
271
- // octane: the INITIAL checked state is the `checked` ATTRIBUTE (React's
272
- // default-state); the live value is driven via the property in the sync effect.
273
- checked: initialCheckedRef.current || undefined,
247
+ checked,
274
248
  disabled,
275
249
  form,
276
250
  id: hiddenInputId,
@@ -0,0 +1,28 @@
1
+ // Ported from .base-ui/packages/react/src/utils/InternalBackdrop.tsx. The always-present backdrop a
2
+ // modal popup renders so Floating UI's outside-press detection sees an element that existed when the
3
+ // popup opened. octane: `createElement`; ref-as-prop.
4
+ import { createElement } from 'octane';
5
+
6
+ export function InternalBackdrop(props: any): any {
7
+ const { cutout, ref, ...otherProps } = props;
8
+
9
+ let clipPath: string | undefined;
10
+ if (cutout) {
11
+ const rect = cutout.getBoundingClientRect();
12
+ clipPath = `polygon(0% 0%,100% 0%,100% 100%,0% 100%,0% 0%,${rect.left}px ${rect.top}px,${rect.left}px ${rect.bottom}px,${rect.right}px ${rect.bottom}px,${rect.right}px ${rect.top}px,${rect.left}px ${rect.top}px)`;
13
+ }
14
+
15
+ return createElement('div', {
16
+ ref,
17
+ role: 'presentation',
18
+ ['data-base-ui-inert']: '',
19
+ ...otherProps,
20
+ style: {
21
+ position: 'fixed',
22
+ inset: 0,
23
+ userSelect: 'none',
24
+ WebkitUserSelect: 'none',
25
+ clipPath,
26
+ },
27
+ });
28
+ }
@@ -0,0 +1,82 @@
1
+ // Ported verbatim from .base-ui/packages/react/src/utils/adaptiveOriginMiddleware.ts (v1.6.0).
2
+ // Pure @floating-ui middleware (no React) — used only when a popup is anchored to a viewport
3
+ // (`hasViewport`). Rewrites the resolved x/y into `right`/`bottom` origins on the sides that need
4
+ // it so CSS transforms scale from the correct corner. `DEFAULT_SIDES` is the fallback used when the
5
+ // middleware doesn't run (no transition, or `adaptiveOrigin` disabled).
6
+ import { getSide } from '@floating-ui/utils';
7
+
8
+ import { ownerDocument, ownerWindow } from './owner';
9
+
10
+ export const DEFAULT_SIDES = {
11
+ sideX: 'left',
12
+ sideY: 'top',
13
+ } as const;
14
+
15
+ export const adaptiveOrigin: any = {
16
+ name: 'adaptiveOrigin',
17
+ async fn(state: any) {
18
+ const {
19
+ x: rawX,
20
+ y: rawY,
21
+ rects: { floating: floatRect },
22
+ elements: { floating },
23
+ platform,
24
+ strategy,
25
+ placement,
26
+ } = state;
27
+
28
+ const win = ownerWindow(floating);
29
+ const styles = win.getComputedStyle(floating);
30
+ const hasTransition = styles.transitionDuration !== '0s' && styles.transitionDuration !== '';
31
+
32
+ if (!hasTransition) {
33
+ return {
34
+ x: rawX,
35
+ y: rawY,
36
+ data: DEFAULT_SIDES,
37
+ };
38
+ }
39
+
40
+ const offsetParent = await platform.getOffsetParent?.(floating);
41
+
42
+ let offsetDimensions = { width: 0, height: 0 };
43
+
44
+ // For fixed strategy, prefer visualViewport if available
45
+ if (strategy === 'fixed' && win?.visualViewport) {
46
+ offsetDimensions = {
47
+ width: win.visualViewport.width,
48
+ height: win.visualViewport.height,
49
+ };
50
+ } else if (offsetParent === win) {
51
+ const doc = ownerDocument(floating);
52
+ offsetDimensions = {
53
+ width: doc.documentElement.clientWidth,
54
+ height: doc.documentElement.clientHeight,
55
+ };
56
+ } else if (await platform.isElement?.(offsetParent)) {
57
+ offsetDimensions = await platform.getDimensions(offsetParent);
58
+ }
59
+
60
+ const currentSide = getSide(placement);
61
+ let x = rawX;
62
+ let y = rawY;
63
+
64
+ if (currentSide === 'left') {
65
+ x = offsetDimensions.width - (rawX + floatRect.width);
66
+ }
67
+ if (currentSide === 'top') {
68
+ y = offsetDimensions.height - (rawY + floatRect.height);
69
+ }
70
+
71
+ const sideX = currentSide === 'left' ? 'right' : DEFAULT_SIDES.sideX;
72
+ const sideY = currentSide === 'top' ? 'bottom' : DEFAULT_SIDES.sideY;
73
+ return {
74
+ x,
75
+ y,
76
+ data: {
77
+ sideX,
78
+ sideY,
79
+ },
80
+ };
81
+ },
82
+ };
@@ -0,0 +1,61 @@
1
+ // Ported from .base-ui/packages/react/src/utils/closePart.tsx (v1.6.0), octane-adapted
2
+ // (slot-threaded plain-`.ts`). Tracks how many "close" parts (e.g. `Popover.Close`) are mounted
3
+ // inside a popup so the focus manager can decide whether the popup is effectively modal.
4
+ import {
5
+ createContext,
6
+ useContext,
7
+ useState,
8
+ useMemo,
9
+ useLayoutEffect,
10
+ createElement,
11
+ } from 'octane';
12
+
13
+ import { S, subSlot } from '../internal';
14
+ import { useStableCallback } from './useStableCallback';
15
+
16
+ interface ClosePartContextValue {
17
+ register: () => () => void;
18
+ }
19
+
20
+ const ClosePartContext = createContext<ClosePartContextValue | undefined>(undefined);
21
+
22
+ export function useClosePartCount(): { context: ClosePartContextValue; hasClosePart: boolean } {
23
+ const slot = S('useClosePartCount');
24
+ const [closePartCount, setClosePartCount] = useState(0, subSlot(slot, 'count'));
25
+
26
+ const register = useStableCallback(
27
+ () => {
28
+ setClosePartCount((count: number) => count + 1);
29
+ return () => {
30
+ setClosePartCount((count: number) => Math.max(0, count - 1));
31
+ };
32
+ },
33
+ subSlot(slot, 'reg'),
34
+ );
35
+
36
+ const context = useMemo(() => ({ register }), [register], subSlot(slot, 'ctx'));
37
+
38
+ return {
39
+ context,
40
+ hasClosePart: closePartCount > 0,
41
+ };
42
+ }
43
+
44
+ export function ClosePartProvider(props: { value: ClosePartContextValue; children?: any }): any {
45
+ return createElement(ClosePartContext.Provider, {
46
+ value: props.value,
47
+ children: props.children,
48
+ });
49
+ }
50
+
51
+ export function useClosePartRegistration(): void {
52
+ const slot = S('useClosePartRegistration');
53
+ const context = useContext(ClosePartContext);
54
+ useLayoutEffect(
55
+ () => {
56
+ return context?.register();
57
+ },
58
+ [context],
59
+ subSlot(slot, 'eff'),
60
+ );
61
+ }
@@ -0,0 +1,9 @@
1
+ // Ported from .base-ui/packages/react/src/internals/constants.ts (v1.6.0) — the subset the
2
+ // overlay/positioner layer needs. `POPUP_COLLISION_AVOIDANCE` is the default collision config
3
+ // popups pass to `useAnchorPositioning`; `DISABLED_TRANSITIONS_STYLE` disables transitions while
4
+ // a popup is in the `starting` transition phase (so the mount frame doesn't animate).
5
+ export const POPUP_COLLISION_AVOIDANCE = {
6
+ fallbackAxisSide: 'end',
7
+ } as const;
8
+
9
+ export const DISABLED_TRANSITIONS_STYLE = { style: { transition: 'none' } } as const;
@@ -22,6 +22,13 @@ export const REASONS = {
22
22
  scrub: 'scrub',
23
23
  trackPress: 'track-press',
24
24
  drag: 'drag',
25
+ triggerHover: 'trigger-hover',
26
+ triggerFocus: 'trigger-focus',
27
+ outsidePress: 'outside-press',
28
+ closePress: 'close-press',
29
+ focusOut: 'focus-out',
30
+ escapeKey: 'escape-key',
31
+ imperativeAction: 'imperative-action',
25
32
  } as const;
26
33
 
27
34
  export type BaseUIEventReason = (typeof REASONS)[keyof typeof REASONS];
@@ -0,0 +1,9 @@
1
+ // Small DOM type-guards (Base UI uses `@floating-ui/utils/dom`'s isElement/isHTMLElement). jsdom
2
+ // and normal DOM are single-realm here, so `instanceof` is sufficient.
3
+ export function isElement(value: unknown): value is Element {
4
+ return value != null && value instanceof Element;
5
+ }
6
+
7
+ export function isHTMLElement(value: unknown): value is HTMLElement {
8
+ return value != null && value instanceof HTMLElement;
9
+ }
@@ -0,0 +1,7 @@
1
+ // Ported from @base-ui/utils/empty: shared frozen empties + NOOP, compared by identity across the
2
+ // codebase (a stable reference lets effects/memos bail out). NOOP re-exports the existing shared
3
+ // one so there is a single identity.
4
+ export { NOOP } from './noop';
5
+
6
+ export const EMPTY_OBJECT = {} as Record<string, never>;
7
+ export const EMPTY_ARRAY = [] as never[];