@octanejs/base-ui 0.1.11 → 0.1.15

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@octanejs/base-ui",
3
- "version": "0.1.11",
3
+ "version": "0.1.15",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "engines": {
@@ -41,19 +41,19 @@
41
41
  "@floating-ui/utils": "^0.2.12",
42
42
  "aria-hidden": "^1.2.6",
43
43
  "tabbable": "^6.5.0",
44
- "@octanejs/floating-ui": "0.1.12"
44
+ "@octanejs/floating-ui": "0.1.16"
45
45
  },
46
46
  "peerDependencies": {
47
- "octane": "0.1.13"
47
+ "octane": "0.1.17"
48
48
  },
49
49
  "devDependencies": {
50
50
  "@base-ui/react": "^1.6.0",
51
- "@tsrx/react": "^0.2.46",
51
+ "@tsrx/react": "^0.2.50",
52
52
  "esbuild": "^0.28.1",
53
53
  "react": "^19.2.7",
54
54
  "react-dom": "^19.2.7",
55
55
  "vitest": "^4.1.10",
56
- "octane": "0.1.13"
56
+ "octane": "0.1.17"
57
57
  },
58
58
  "scripts": {
59
59
  "test": "vitest run"
@@ -40,8 +40,10 @@ export const AlertDialog = {
40
40
  Portal: Dialog.Portal,
41
41
  Backdrop: Dialog.Backdrop,
42
42
  Popup: Dialog.Popup,
43
+ Viewport: Dialog.Viewport,
43
44
  Title: Dialog.Title,
44
45
  Description: Dialog.Description,
45
46
  Close: Dialog.Close,
46
47
  createHandle: createAlertDialogHandle,
48
+ Handle: AlertDialogHandle,
47
49
  };
package/src/button.ts ADDED
@@ -0,0 +1,63 @@
1
+ // Ported from .base-ui/packages/react/src/button/Button.tsx (v1.6.0). A button that can be
2
+ // used to trigger actions. Renders a `<button>` element.
3
+ import { S, subSlot } from './internal';
4
+ import { useButton } from './utils/useButton';
5
+ import { useRenderElement } from './utils/useRenderElement';
6
+
7
+ export interface ButtonState {
8
+ /** Whether the button should ignore user interaction. */
9
+ disabled: boolean;
10
+ }
11
+
12
+ export interface ButtonProps {
13
+ disabled?: boolean;
14
+ /**
15
+ * Whether the button should be focusable when disabled.
16
+ * @default false
17
+ */
18
+ focusableWhenDisabled?: boolean;
19
+ /**
20
+ * Whether the component renders a native `<button>` element when replacing it via the
21
+ * `render` prop.
22
+ * @default true
23
+ */
24
+ nativeButton?: boolean;
25
+ className?: string | ((state: ButtonState) => string | undefined);
26
+ render?: import('./utils/useRenderElement').RenderProp<ButtonState>;
27
+ ref?: any;
28
+ [key: string]: any;
29
+ }
30
+
31
+ // octane: forwardRef → ref-as-prop.
32
+ export function Button(props: ButtonProps): any {
33
+ const slot = S('Button');
34
+ const {
35
+ render,
36
+ className,
37
+ disabled = false,
38
+ focusableWhenDisabled = false,
39
+ nativeButton = true,
40
+ style,
41
+ ref,
42
+ ...elementProps
43
+ } = props;
44
+
45
+ const { getButtonProps, buttonRef } = useButton(
46
+ { disabled, focusableWhenDisabled, native: nativeButton },
47
+ subSlot(slot, 'btn'),
48
+ );
49
+
50
+ const state: ButtonState = { disabled };
51
+
52
+ return useRenderElement(
53
+ 'button',
54
+ { className, render, style },
55
+ { state, ref: [ref, buttonRef], props: [elementProps, getButtonProps] },
56
+ subSlot(slot, 're'),
57
+ );
58
+ }
59
+
60
+ export namespace Button {
61
+ export type Props = ButtonProps;
62
+ export type State = ButtonState;
63
+ }
@@ -0,0 +1,37 @@
1
+ // Ported from .base-ui/packages/react/src/csp-provider/CSPProvider.tsx (v1.6.0). Provides a
2
+ // default Content Security Policy configuration for Base UI components that require inline
3
+ // `<style>` or `<script>` tags.
4
+ import { createElement, useMemo } from 'octane';
5
+
6
+ import { S, subSlot } from './internal';
7
+ import { CSPContext } from './utils/CSPContext';
8
+ import type { CSPContextValue } from './utils/CSPContext';
9
+
10
+ export interface CSPProviderProps {
11
+ children?: any;
12
+ /** The nonce value to apply to inline `<style>` and `<script>` tags. */
13
+ nonce?: string;
14
+ /**
15
+ * Whether inline `<style>` elements created by Base UI components should not be rendered.
16
+ * Instead, components must specify the CSS styles via custom class names or other methods.
17
+ * @default false
18
+ */
19
+ disableStyleElements?: boolean;
20
+ }
21
+
22
+ export function CSPProvider(props: CSPProviderProps): any {
23
+ const slot = S('CSPProvider');
24
+ const { children, nonce, disableStyleElements } = props;
25
+
26
+ const contextValue: CSPContextValue = useMemo(
27
+ () => ({ nonce, disableStyleElements }),
28
+ [nonce, disableStyleElements],
29
+ subSlot(slot, 'ctx'),
30
+ );
31
+
32
+ return createElement(CSPContext.Provider, { value: contextValue, children });
33
+ }
34
+
35
+ export namespace CSPProvider {
36
+ export type Props = CSPProviderProps;
37
+ }
package/src/dialog.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  import {
10
10
  createContext,
11
11
  createElement,
12
+ Fragment,
12
13
  useContext,
13
14
  useMemo,
14
15
  useRef,
@@ -33,7 +34,11 @@ import { useDismiss } from './utils/floating/useDismiss';
33
34
  import { useScrollLock } from './utils/useScrollLock';
34
35
  import { useOpenChangeComplete } from './utils/useOpenChangeComplete';
35
36
  import { useOpenMethodTriggerProps } from './utils/useOpenInteractionType';
36
- import { triggerOpenStateMapping, popupStateMapping } from './utils/popupStateMapping';
37
+ import {
38
+ triggerOpenStateMapping,
39
+ popupStateMapping,
40
+ CommonPopupDataAttributes,
41
+ } from './utils/popupStateMapping';
37
42
  import { transitionStatusMapping } from './utils/useTransitionStatus';
38
43
  import type { StateAttributesMapping } from './utils/getStateAttributesProps';
39
44
  import { EMPTY_OBJECT } from './utils/empty';
@@ -404,14 +409,7 @@ function DialogInteractions(props: any): any {
404
409
  subSlot(slot, 'pip'),
405
410
  );
406
411
 
407
- // Renders the Dialog's children (see the note at the `content` call site). No DOM of its own.
408
- return props.children ?? null;
409
- }
410
-
411
- // Passthrough used when the interactions aren't mounted, to keep the Provider's children a stable
412
- // element-descriptor shape (see the note at the `content` call site).
413
- function DialogChildren(props: any): any {
414
- return props.children ?? null;
412
+ return null;
415
413
  }
416
414
 
417
415
  export function useRenderDialogRoot<Payload>(
@@ -482,19 +480,19 @@ export function useRenderDialogRoot<Payload>(
482
480
  const resolvedChildren =
483
481
  typeof children === 'function' && !isChildrenBlock(children) ? children({ payload }) : children;
484
482
 
485
- // The Provider's `children` must stay a STABLE shape (always an element descriptor) across
486
- // renders: octane's `childrenAsBody` runs a render-FUNCTION child directly in the Provider scope
487
- // (owning `scope.slots`) but routes a DESCRIPTOR child through `childSlot(scope, 0)` — alternating
488
- // the two across renders (a raw children-block when closed vs a component when open) collides those
489
- // slot namespaces and crashes octane's reconciler (see octane bug note). So a no-DOM component
490
- // wraps the children in BOTH states: `DialogInteractions` (runs the open-state hooks) when
491
- // open/mounted, else `DialogChildren` (a passthrough). Both just render `props.children`.
492
- const content = createElement(shouldRenderInteractions ? DialogInteractions : DialogChildren, {
493
- store,
494
- parentContext: parentDialogRootContext?.store.context,
495
- isDrawer,
496
- children: resolvedChildren,
497
- });
483
+ // `DialogInteractions` is a headless SIBLING of the children, exactly as Base UI renders it, so
484
+ // toggling it never disturbs them.
485
+ const content = [
486
+ shouldRenderInteractions
487
+ ? createElement(DialogInteractions, {
488
+ key: 'interactions',
489
+ store,
490
+ parentContext: parentDialogRootContext?.store.context,
491
+ isDrawer,
492
+ })
493
+ : null,
494
+ createElement(Fragment, { key: 'children', children: resolvedChildren }),
495
+ ];
498
496
 
499
497
  return createElement(IsDrawerContext.Provider, {
500
498
  value: false,
@@ -844,6 +842,73 @@ function DialogClose(componentProps: any): any {
844
842
  );
845
843
  }
846
844
 
845
+ // --- Viewport ----------------------------------------------------------------
846
+
847
+ export const DialogViewportDataAttributes = {
848
+ open: CommonPopupDataAttributes.open,
849
+ closed: CommonPopupDataAttributes.closed,
850
+ startingStyle: CommonPopupDataAttributes.startingStyle,
851
+ endingStyle: CommonPopupDataAttributes.endingStyle,
852
+ nested: 'data-nested',
853
+ nestedDialogOpen: 'data-nested-dialog-open',
854
+ } as const;
855
+
856
+ const viewportStateAttributesMapping: StateAttributesMapping<any> = {
857
+ ...(popupStateMapping as StateAttributesMapping<any>),
858
+ ...(transitionStatusMapping as StateAttributesMapping<any>),
859
+ nested(value: boolean) {
860
+ return value ? { [DialogViewportDataAttributes.nested]: '' } : null;
861
+ },
862
+ nestedDialogOpen(value: boolean) {
863
+ return value ? { [DialogViewportDataAttributes.nestedDialogOpen]: '' } : null;
864
+ },
865
+ };
866
+
867
+ // A positioning container for the dialog popup that can be made scrollable.
868
+ function DialogViewport(componentProps: any): any {
869
+ const slot = S('DialogViewport');
870
+ const { render, className, style, children, ref, ...elementProps } = componentProps;
871
+
872
+ const keepMounted = useDialogPortalContext();
873
+ const { store } = useDialogRootContext() as DialogRootContextValue;
874
+
875
+ const open = store.useState('open', subSlot(slot, 'open'));
876
+ const nested = store.useState('nested', subSlot(slot, 'nested'));
877
+ const transitionStatus = store.useState('transitionStatus', subSlot(slot, 'ts'));
878
+ const nestedOpenDialogCount = store.useState('nestedOpenDialogCount', subSlot(slot, 'nodc'));
879
+ const mounted = store.useState('mounted', subSlot(slot, 'mounted'));
880
+
881
+ const setViewportElement = store.useStateSetter('viewportElement', subSlot(slot, 'sve'));
882
+
883
+ const state = {
884
+ open,
885
+ nested,
886
+ transitionStatus,
887
+ nestedDialogOpen: nestedOpenDialogCount > 0,
888
+ };
889
+
890
+ return useRenderElement(
891
+ 'div',
892
+ { render, className, style },
893
+ {
894
+ enabled: keepMounted || mounted,
895
+ state,
896
+ ref: [ref, setViewportElement],
897
+ stateAttributesMapping: viewportStateAttributesMapping,
898
+ props: [
899
+ {
900
+ role: 'presentation',
901
+ hidden: !mounted,
902
+ style: { pointerEvents: !open ? 'none' : undefined },
903
+ children,
904
+ },
905
+ elementProps,
906
+ ],
907
+ },
908
+ subSlot(slot, 're'),
909
+ );
910
+ }
911
+
847
912
  // --- Namespace ---------------------------------------------------------------
848
913
 
849
914
  export const Dialog = {
@@ -852,8 +917,10 @@ export const Dialog = {
852
917
  Portal: DialogPortal,
853
918
  Backdrop: DialogBackdrop,
854
919
  Popup: DialogPopup,
920
+ Viewport: DialogViewport,
855
921
  Title: DialogTitle,
856
922
  Description: DialogDescription,
857
923
  Close: DialogClose,
858
924
  createHandle: createDialogHandle,
925
+ Handle: DialogHandle,
859
926
  };
@@ -0,0 +1,32 @@
1
+ // Ported from .base-ui/packages/react/src/direction-provider/DirectionProvider.tsx (v1.6.0).
2
+ // Enables RTL behavior for Base UI components.
3
+ //
4
+ // octane adaptation: `utils/DirectionContext` carries the `TextDirection` string DIRECTLY
5
+ // rather than Base UI's `{ direction }` wrapper object, so the provider needs no `useMemo`
6
+ // to stay referentially stable. The context value is never observable in the DOM, and every
7
+ // consumer reads it through `useDirection()`.
8
+ import { createElement } from 'octane';
9
+
10
+ import { DirectionContext, useDirection } from './utils/DirectionContext';
11
+ import type { TextDirection } from './utils/DirectionContext';
12
+
13
+ export type { TextDirection };
14
+ export { useDirection };
15
+
16
+ export interface DirectionProviderProps {
17
+ children?: any;
18
+ /**
19
+ * The reading direction of the text.
20
+ * @default 'ltr'
21
+ */
22
+ direction?: TextDirection;
23
+ }
24
+
25
+ export function DirectionProvider(props: DirectionProviderProps): any {
26
+ const { direction = 'ltr' } = props;
27
+ return createElement(DirectionContext.Provider, { value: direction, children: props.children });
28
+ }
29
+
30
+ export namespace DirectionProvider {
31
+ export type Props = DirectionProviderProps;
32
+ }
package/src/index.ts CHANGED
@@ -5,6 +5,11 @@
5
5
  export { Separator } from './separator';
6
6
  export { AlertDialog } from './alert-dialog';
7
7
  export { Avatar } from './avatar';
8
+ export { Button } from './button';
9
+ export { CSPProvider } from './csp-provider';
10
+ export { DirectionProvider, useDirection } from './direction-provider';
11
+ export type { TextDirection } from './direction-provider';
12
+ export { useMediaQuery } from './unstable-use-media-query';
8
13
  export { Checkbox } from './checkbox';
9
14
  export { CheckboxGroup } from './checkbox-group';
10
15
  export { Dialog } from './dialog';
@@ -18,6 +23,8 @@ export { Progress } from './progress';
18
23
  export { Radio } from './radio';
19
24
  export { RadioGroup } from './radio-group';
20
25
  export { Popover } from './popover';
26
+ export { PreviewCard } from './preview-card';
27
+ export { Tooltip } from './tooltip';
21
28
  export { Slider } from './slider';
22
29
  export { Switch } from './switch';
23
30
  export { Toggle } from './toggle';
package/src/popover.ts CHANGED
@@ -1,16 +1,16 @@
1
1
  // Ported from .base-ui/packages/react/src/popover/ (v1.6.0): store (PopoverStore/PopoverHandle),
2
- // root (context/PopoverRoot/PopoverInteractions), trigger. The CLOSED-state path (Root + Trigger),
3
- // reusing the Store + floating stack from Dialog. octane adaptations: forwardRef → ref-as-prop;
4
- // native events; every Store hook-method + hook threads an explicit slot; `React.createContext` →
5
- // octane `createContext`; `ReactDOM.flushSync` → octane `flushSync`.
2
+ // root (context/PopoverRoot/PopoverInteractions), trigger, and the anchored open path
3
+ // (Portal/Positioner/Popup/Arrow/Backdrop/Title/Description/Close), reusing the Store + floating
4
+ // stack from Dialog. octane adaptations: forwardRef → ref-as-prop; native events; every Store
5
+ // hook-method + hook threads an explicit slot; `React.createContext` → octane `createContext`;
6
+ // `ReactDOM.flushSync` → octane `flushSync`.
6
7
  //
7
- // STUBBED/DEFERRED (behind off-by-default features): `useHoverReferenceInteraction` (openOnHover);
8
- // the Positioner + Popup + Arrow + Backdrop + Title/Description/Close parts (the open path) land next
9
- // with `useAnchorPositioning`. Interactions are only mounted when `open || mounted`, and — as with
10
- // Dialog — a stable no-DOM descriptor wraps the children (octane Provider children shape-flip bug).
8
+ // `openOnHover` is fully wired: the trigger side runs `useHoverReferenceInteraction` (+ safePolygon)
9
+ // and the popup side runs `useHoverFloatingInteraction`.
11
10
  import {
12
11
  createContext,
13
12
  createElement,
13
+ Fragment,
14
14
  useContext,
15
15
  useMemo,
16
16
  useRef,
@@ -51,6 +51,7 @@ import {
51
51
  type UseAnchorPositioningSharedParameters,
52
52
  } from './utils/useAnchorPositioning';
53
53
  import { usePositioner } from './utils/usePositioner';
54
+ import { usePopupViewport } from './utils/usePopupViewport';
54
55
  import { useAnchoredPopupScrollLock } from './utils/useAnchoredPopupScrollLock';
55
56
  import { adaptiveOrigin } from './utils/adaptiveOriginMiddleware';
56
57
  import { InternalBackdrop } from './utils/InternalBackdrop';
@@ -415,11 +416,7 @@ function PopoverInteractions(props: any): any {
415
416
  subSlot(slot, 'pip'),
416
417
  );
417
418
 
418
- return props.children ?? null;
419
- }
420
-
421
- function PopoverChildren(props: any): any {
422
- return props.children ?? null;
419
+ return null;
423
420
  }
424
421
 
425
422
  // --- Root --------------------------------------------------------------------
@@ -519,12 +516,16 @@ function PopoverRootComponent<Payload>(props: any): any {
519
516
  const resolvedChildren =
520
517
  typeof children === 'function' && !isChildrenBlock(children) ? children({ payload }) : children;
521
518
 
522
- // Stable descriptor shape (see the Dialog note / octane Provider children shape-flip bug).
523
- const content = createElement(shouldRenderInteractions ? PopoverInteractions : PopoverChildren, {
524
- store,
525
- modal,
526
- children: resolvedChildren,
527
- });
519
+ // `PopoverInteractions` is a headless SIBLING of the children, exactly as Base UI renders it, so
520
+ // toggling it never disturbs them. Rendering it as a WRAPPER instead swapped the wrapper's
521
+ // component type on every open, which tore down and rebuilt the whole subtree — including the
522
+ // trigger, whose element listeners and store registration then pointed at a detached node.
523
+ const content = [
524
+ shouldRenderInteractions
525
+ ? createElement(PopoverInteractions, { key: 'interactions', store, modal })
526
+ : null,
527
+ createElement(Fragment, { key: 'children', children: resolvedChildren }),
528
+ ];
528
529
 
529
530
  return createElement(PopoverRootContext.Provider, {
530
531
  value: contextValue as PopoverRootContextValue,
@@ -680,18 +681,33 @@ function PopoverTrigger(componentProps: any): any {
680
681
  subSlot(slot, 're'),
681
682
  );
682
683
 
684
+ // The trigger's children shape flips between a lone element (closed) and
685
+ // [guard, element, guard] (open, non-modal). Base UI keeps the trigger's identity across that
686
+ // flip by wrapping it in a fragment keyed on the trigger id in BOTH branches — without the key
687
+ // the button is torn down and rebuilt on every open, which detaches the element listeners
688
+ // (hover open/close) and makes the positioner see a spurious trigger change.
689
+ const keyedElement = createElement(Fragment, { key: thisTriggerId, children: element });
690
+
683
691
  if (isMountedByThisTrigger && !focusManagerModal) {
692
+ // Base UI renders these as static JSX fragment children; octane reconciles a returned array
693
+ // as a list, so the guards carry stable keys too — otherwise the unkeyed siblings shift the
694
+ // keyed trigger and it is rebuilt anyway.
684
695
  return [
685
- createElement(FocusGuard, { ref: preFocusGuardRef, onFocus: handlePreFocusGuardFocus }),
686
- element,
687
696
  createElement(FocusGuard, {
697
+ key: `${thisTriggerId}-pre-guard`,
698
+ ref: preFocusGuardRef,
699
+ onFocus: handlePreFocusGuardFocus,
700
+ }),
701
+ keyedElement,
702
+ createElement(FocusGuard, {
703
+ key: `${thisTriggerId}-post-guard`,
688
704
  ref: store.context.triggerFocusTargetRef,
689
705
  onFocus: handleFocusTargetFocus,
690
706
  }),
691
707
  ];
692
708
  }
693
709
 
694
- return element;
710
+ return keyedElement;
695
711
  }
696
712
 
697
713
  // --- Portal ------------------------------------------------------------------
@@ -1188,6 +1204,58 @@ function PopoverClose(componentProps: any): any {
1188
1204
  );
1189
1205
  }
1190
1206
 
1207
+ // --- Viewport ----------------------------------------------------------------
1208
+
1209
+ export const PopoverViewportCssVars = {
1210
+ /**
1211
+ * The width of the parent popup when the previous content was rendered. Placed on the
1212
+ * 'previous' container so the popup's dimensions can be frozen while content animates.
1213
+ */
1214
+ popupWidth: '--popup-width',
1215
+ /** The height of the parent popup when the previous content was rendered. */
1216
+ popupHeight: '--popup-height',
1217
+ } as const;
1218
+
1219
+ const viewportStateAttributesMapping: StateAttributesMapping<any> = {
1220
+ activationDirection: (value: string | undefined) =>
1221
+ value ? { 'data-activation-direction': value } : null,
1222
+ };
1223
+
1224
+ // A viewport for displaying content transitions. Only needed when one popup can be opened by
1225
+ // multiple triggers, its content changes per trigger, and switching between them is animated.
1226
+ function PopoverViewport(componentProps: any): any {
1227
+ const slot = S('PopoverViewport');
1228
+ const { render, className, style, children, ref, ...elementProps } = componentProps;
1229
+
1230
+ const { store } = usePopoverRootContext() as PopoverRootContextValue;
1231
+ const { side } = usePopoverPositionerContext();
1232
+
1233
+ const instantType = store.useState('instantType', subSlot(slot, 'instant'));
1234
+
1235
+ const { children: childrenToRender, state: viewportState } = usePopupViewport(
1236
+ { store, side, cssVars: PopoverViewportCssVars, children },
1237
+ subSlot(slot, 'vp'),
1238
+ );
1239
+
1240
+ const state = {
1241
+ activationDirection: viewportState.activationDirection,
1242
+ transitioning: viewportState.transitioning,
1243
+ instant: instantType,
1244
+ };
1245
+
1246
+ return useRenderElement(
1247
+ 'div',
1248
+ { render, className, style },
1249
+ {
1250
+ state,
1251
+ ref,
1252
+ props: [elementProps, { children: childrenToRender }],
1253
+ stateAttributesMapping: viewportStateAttributesMapping,
1254
+ },
1255
+ subSlot(slot, 're'),
1256
+ );
1257
+ }
1258
+
1191
1259
  // --- Namespace ---------------------------------------------------------------
1192
1260
 
1193
1261
  export const Popover = {
@@ -1196,10 +1264,12 @@ export const Popover = {
1196
1264
  Portal: PopoverPortal,
1197
1265
  Positioner: PopoverPositioner,
1198
1266
  Popup: PopoverPopup,
1267
+ Viewport: PopoverViewport,
1199
1268
  Arrow: PopoverArrow,
1200
1269
  Backdrop: PopoverBackdrop,
1201
1270
  Title: PopoverTitle,
1202
1271
  Description: PopoverDescription,
1203
1272
  Close: PopoverClose,
1204
1273
  createHandle: createPopoverHandle,
1274
+ Handle: PopoverHandle,
1205
1275
  };