@enonic/ui 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,5 @@
1
1
  import { ComponentPropsWithoutRef, ReactElement, ReactNode, RefObject } from 'react';
2
+ import { FloatingProps } from '../../hooks';
2
3
  export type TimePickerHourSelectProps = {
3
4
  className?: string;
4
5
  contentClassName?: string;
@@ -22,12 +23,11 @@ export type TimePickerTriggerProps = {
22
23
  children?: ReactNode;
23
24
  } & Omit<ComponentPropsWithoutRef<'button'>, 'className' | 'children'>;
24
25
  export type TimePickerContentProps = {
25
- align?: 'start' | 'end';
26
26
  /** Optional reference to an anchor element for positioning (defaults to trigger) */
27
27
  anchorRef?: RefObject<HTMLElement>;
28
28
  className?: string;
29
29
  children?: ReactNode;
30
- } & Omit<ComponentPropsWithoutRef<'div'>, 'className' | 'children'>;
30
+ } & FloatingProps & Omit<ComponentPropsWithoutRef<'div'>, 'className' | 'children'>;
31
31
  export type TimePickerRootProps = {
32
32
  value?: string | null;
33
33
  defaultValue?: string | null;
@@ -4,7 +4,7 @@ export { type UseBlinkAttentionConfig, useBlinkAttention } from './use-blink-att
4
4
  export { type UseClickOutsideConfig, useClickOutside } from './use-click-outside';
5
5
  export { useControlledState } from './use-controlled-state';
6
6
  export { useControlledStateWithNull } from './use-controlled-state-with-null';
7
- export { type FloatingPosition, type FloatingSide, type UseFloatingPositionConfig, useFloatingPosition, } from './use-floating-position';
7
+ export { type CollisionStrategy, type FloatingPosition, type FloatingProps, type FloatingSide, toFloatingStyle, type UseFloatingPositionConfig, useFloatingPosition, } from './use-floating-position';
8
8
  export { type ActiveCell, type GridNavigationConfig, type UseGridNavigationReturn, useGridNavigation, } from './use-grid-navigation';
9
9
  export { type ItemMetadata, type UseItemRegistryReturn, useItemRegistry } from './use-item-registry';
10
10
  export { type UseItemTextRegistryReturn, useItemTextRegistry } from './use-item-text-registry';
@@ -1,11 +1,30 @@
1
- import { RefObject } from 'react';
1
+ import { CSSProperties, RefObject } from 'react';
2
2
  export type FloatingSide = 'top' | 'bottom' | 'left' | 'right';
3
+ export type CollisionStrategy = 'flip' | 'shrink';
3
4
  export type FloatingPosition = {
4
5
  top: number;
5
6
  left?: number;
6
7
  right?: number;
7
- /** Which side the popup is positioned relative to the anchor (post-flip) */
8
+ /**
9
+ * The side the popup is actually positioned on. Equals `preferredSide`
10
+ * unless `collisionStrategy` is `'flip'` and the popup was flipped to fit.
11
+ */
8
12
  side: FloatingSide;
13
+ /** The `side` requested via config — useful when `flipped` is true. */
14
+ preferredSide: FloatingSide;
15
+ /** True when the popup could not use `preferredSide`. */
16
+ flipped: boolean;
17
+ /**
18
+ * Maximum height the popup should be clamped to, in pixels. Set whenever
19
+ * `collisionStrategy` is `'shrink'`. Consumers apply it via inline style on
20
+ * the popup wrapper; it only takes effect when the content is taller.
21
+ *
22
+ * A popup whose items are direct children also needs `overflow-y-auto` to
23
+ * scroll. One that wraps a scrollable child (a listbox or viewport) must not:
24
+ * constraining the wrapper alone keeps the child the scroller, which is what
25
+ * scroll-active-into-view relies on.
26
+ */
27
+ maxHeight?: number;
9
28
  };
10
29
  export type UseFloatingPositionConfig = {
11
30
  /** Whether the floating element is open/visible */
@@ -15,8 +34,7 @@ export type UseFloatingPositionConfig = {
15
34
  /** Reference to the floating content element */
16
35
  contentRef: RefObject<HTMLElement> | null;
17
36
  /**
18
- * Preferred side relative to the anchor. Flips to the opposite side when
19
- * the content would overflow the viewport.
37
+ * Preferred side relative to the anchor.
20
38
  * @default 'bottom'
21
39
  */
22
40
  side?: FloatingSide;
@@ -27,16 +45,45 @@ export type UseFloatingPositionConfig = {
27
45
  * @default 'start'
28
46
  */
29
47
  align?: 'start' | 'end';
48
+ /**
49
+ * How to react when the preferred side cannot fit the popup:
50
+ * - `'flip'` (default) — flip to the opposite side if it fits; else stay on
51
+ * the preferred side and overflow.
52
+ * - `'shrink'` — keep the preferred side and return the available space as
53
+ * `maxHeight`, which the consumer applies to enable internal scroll. Falls
54
+ * back to the opposite side when the preferred one is too cramped to use.
55
+ *
56
+ * @default 'flip'
57
+ */
58
+ collisionStrategy?: CollisionStrategy;
59
+ };
60
+ /**
61
+ * Projects a position into an inline style, keeping the coordinate fields and
62
+ * dropping the descriptive ones (`side`, `preferredSide`, `flipped`) that would
63
+ * otherwise leak into the DOM as invalid CSS.
64
+ */
65
+ export declare function toFloatingStyle(position: FloatingPosition | null): CSSProperties;
66
+ /**
67
+ * Shared prop surface for popup components that build on `useFloatingPosition`.
68
+ * Mix into a component's `*Props` type to expose consistent positioning controls
69
+ * across the library.
70
+ */
71
+ export type FloatingProps = {
72
+ /** Preferred side relative to the anchor. @default 'bottom' */
73
+ side?: FloatingSide;
74
+ /** Alignment along the perpendicular axis. @default 'start' */
75
+ align?: 'start' | 'end';
76
+ /** Collision behavior when the preferred side does not fit. @default 'flip' */
77
+ collisionStrategy?: CollisionStrategy;
30
78
  };
31
79
  /**
32
80
  * Calculates optimal position for floating elements (menus, dropdowns, submenus) relative
33
- * to an anchor, with automatic viewport collision detection and flip behavior.
81
+ * to an anchor, with viewport collision handling controlled by `collisionStrategy`.
34
82
  *
35
- * Supports four preferred sides (top/bottom/left/right). When the content would overflow
36
- * the viewport on the preferred side, it flips to the opposite side. Perpendicular-axis
37
- * alignment is adjusted to keep the content within the viewport.
83
+ * Supports four preferred sides (top/bottom/left/right). Perpendicular-axis alignment is
84
+ * always adjusted to keep the content within the viewport, regardless of strategy.
38
85
  *
39
86
  * @param config - Configuration object for positioning behavior
40
- * @returns Position object with top and left/right coordinates, or null if not yet calculated
87
+ * @returns Position object with coordinates and side info, or null if not yet calculated
41
88
  */
42
- export declare function useFloatingPosition({ enabled, anchorRef, contentRef, side: preferredSide, align, }: UseFloatingPositionConfig): FloatingPosition | null;
89
+ export declare function useFloatingPosition({ enabled, anchorRef, contentRef, side: preferredSide, align, collisionStrategy, }: UseFloatingPositionConfig): FloatingPosition | null;
@@ -11,11 +11,12 @@ import { RefObject } from 'react';
11
11
  * ```tsx
12
12
  * const contentRef = useRef<HTMLDivElement>(null);
13
13
  * const [isPortalMode, setIsPortalMode] = useState(false);
14
+ * const portalContainer = usePortalContainer();
14
15
  *
15
16
  * useLayoutEffect(() => {
16
17
  * if (!open || !contentRef.current) return;
17
- * setIsPortalMode(contentRef.current.parentElement === document.body);
18
- * }, [open]);
18
+ * setIsPortalMode(contentRef.current.parentElement === portalContainer);
19
+ * }, [open, portalContainer]);
19
20
  *
20
21
  * usePortalFocusContainer(contentRef, isPortalMode);
21
22
  * ```
@@ -1,7 +1,9 @@
1
+ import { RefObject } from 'react';
1
2
  export type RadioNavigationConfig = {
2
3
  baseId: string;
3
4
  getItems: () => string[];
4
5
  getItemId?: (baseId: string, itemId: string) => string;
6
+ rootRef?: RefObject<HTMLElement>;
5
7
  value: string | undefined;
6
8
  onValueChange: (value: string) => void;
7
9
  isItemDisabled: (id: string) => boolean;
@@ -1,7 +1,9 @@
1
+ import { RefObject } from 'react';
1
2
  export type StepNavigationConfig = {
2
3
  baseId: string;
3
4
  getItems: () => string[];
4
5
  getNextFocusable?: (baseId: string, itemId: string) => string;
6
+ rootRef?: RefObject<HTMLElement>;
5
7
  value: string | undefined;
6
8
  onValueChange: (value: string) => void;
7
9
  isItemDisabled: (id: string) => boolean;
@@ -2,6 +2,8 @@ import { ReactElement, ReactNode, RefObject } from 'react';
2
2
  export type ContentType = 'listbox' | 'tree' | 'auto';
3
3
  export type ComboboxOpenOptions = {
4
4
  restoreFocus?: boolean;
5
+ /** Whether this interaction should focus the input on open or restore it on close. */
6
+ focusInput?: boolean;
5
7
  };
6
8
  export type ComboboxContextValue = {
7
9
  baseId: string;
@@ -10,6 +12,7 @@ export type ComboboxContextValue = {
10
12
  applyRef: RefObject<HTMLButtonElement>;
11
13
  popupRef: RefObject<HTMLDivElement>;
12
14
  restoreFocusOnCloseRef: RefObject<boolean>;
15
+ inputFocusIntentRef: RefObject<boolean>;
13
16
  ignoreFocusExitCloseRef: RefObject<boolean>;
14
17
  open: boolean;
15
18
  setOpen: (next: boolean, options?: ComboboxOpenOptions) => void;
@@ -12,6 +12,7 @@ export * from './listbox-provider';
12
12
  export * from './menu-provider';
13
13
  export * from './menubar-menu-provider';
14
14
  export * from './menubar-provider';
15
+ export * from './portal-provider';
15
16
  export * from './radio-provider';
16
17
  export * from './search-field-provider';
17
18
  export * from './selector-provider';
@@ -0,0 +1,21 @@
1
+ import { ReactElement, ReactNode } from 'react';
2
+ export type PortalProviderProps = {
3
+ children: ReactNode;
4
+ /** The element every overlay in the subtree portals into. `null` falls back to `document.body`. */
5
+ container: HTMLElement | null;
6
+ };
7
+ /**
8
+ * Overrides where overlays (dialogs, menus, tooltips, popups) portal to. Without a provider they
9
+ * portal to `document.body`, which lies outside any shadow root.
10
+ */
11
+ export declare const PortalProvider: {
12
+ ({ children, container }: PortalProviderProps): ReactElement;
13
+ displayName: string;
14
+ };
15
+ /**
16
+ * Resolves the portal container: an explicit `container` prop wins, then the nearest
17
+ * `PortalProvider`, then `document.body`.
18
+ *
19
+ * `null` means there is no document (server render) — callers must not portal until it is set.
20
+ */
21
+ export declare const usePortalContainer: (container?: HTMLElement | null) => HTMLElement | null;
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The package's compiled stylesheet as text, for consumers without a Tailwind build rendering
3
+ * inside a shadow root: construct a sheet from it once and pass that to `AppRoot`'s `stylesheets`.
4
+ * With your own Tailwind v4 build, pass your compiled CSS instead — never both.
5
+ */
6
+ export declare const styleText: string;
7
+ /** `styleText` as a constructed stylesheet, built once on first call. Browser-only. */
8
+ export declare const getStyleSheet: () => CSSStyleSheet;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Whether a node is a shadow root. Checked by shape rather than `instanceof`, which only recognises
3
+ * the realm the library was loaded in — a root owned by an iframe document would not match.
4
+ */
5
+ export declare const isShadowRoot: (node: Node | null | undefined) => node is ShadowRoot;
6
+ /**
7
+ * The document or shadow root a node lives in — the scope id and selector lookups must go through,
8
+ * since `document.getElementById` stops at a shadow boundary.
9
+ *
10
+ * `null` when there is nothing to resolve against: no node, and no document either, as on a server.
11
+ */
12
+ export declare const getRoot: (node: Node | null | undefined) => Document | ShadowRoot | null;
13
+ /**
14
+ * The deeply focused element. From outside a shadow root `document.activeElement` reports the host
15
+ * rather than the focused element inside it. Stops at a closed root, whose `shadowRoot` is hidden.
16
+ */
17
+ export declare const getActiveElement: () => Element | null;
18
+ /**
19
+ * Whether a container holds a node, crossing shadow boundaries. `Node.contains` stops at them, so a
20
+ * node focused inside a nested root reads as outside the host that holds it.
21
+ */
22
+ export declare const containsDeep: (container: Node | null | undefined, node: Node | null | undefined) => boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enonic/ui",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Enonic UI Component Library",
5
5
  "author": "Enonic",
6
6
  "license": "Apache-2.0",
@@ -33,6 +33,8 @@
33
33
  "files": [
34
34
  "dist/enonic-ui.es.js",
35
35
  "dist/enonic-ui.cjs",
36
+ "dist/style.es.js",
37
+ "dist/style.cjs",
36
38
  "dist/styles/",
37
39
  "dist/types/"
38
40
  ],
@@ -42,6 +44,11 @@
42
44
  "import": "./dist/enonic-ui.es.js",
43
45
  "require": "./dist/enonic-ui.cjs"
44
46
  },
47
+ "./style": {
48
+ "types": "./dist/types/style.d.ts",
49
+ "import": "./dist/style.es.js",
50
+ "require": "./dist/style.cjs"
51
+ },
45
52
  "./style.css": "./dist/styles/style.css",
46
53
  "./preset.css": "./dist/styles/preset.css",
47
54
  "./tokens.css": "./dist/styles/tokens.css",
@@ -91,13 +98,16 @@
91
98
  "@storybook/addon-docs": "~10.5.5",
92
99
  "@storybook/addon-themes": "~10.5.5",
93
100
  "@storybook/preact-vite": "~10.5.5",
101
+ "@storybook/test-runner": "^0.24.4",
94
102
  "@tailwindcss/vite": "~4.3.3",
95
103
  "@types/node": "~24.13.3",
96
104
  "@types/react": "~19.2.18",
97
105
  "@types/react-dom": "~19.2.4",
98
106
  "@typescript/typescript6": "~6.0.2",
99
107
  "focus-trap-react": "^12.0.3",
100
- "lucide-preact": "~1.28.0",
108
+ "http-server": "^14.1.1",
109
+ "lucide-preact": "~1.31.0",
110
+ "playwright": "^1.62.1",
101
111
  "react-virtuoso": "^4.18.11",
102
112
  "rollup-plugin-visualizer": "~7.0.1",
103
113
  "size-limit": "~13.0.3",
@@ -106,10 +116,11 @@
106
116
  "terser": "~5.49.0",
107
117
  "tw-animate-css": "^1.4.0",
108
118
  "typescript": "~7.0.2",
109
- "vite": "npm:@voidzero-dev/vite-plus-core@^0.2.7",
119
+ "vite": "npm:@voidzero-dev/vite-plus-core@^0.2.8",
110
120
  "vite-plugin-dts": "~5.0.3",
111
121
  "vite-plugin-environment": "~1.1.3",
112
- "vite-plus": "^0.2.7"
122
+ "vite-plus": "^0.2.8",
123
+ "wait-on": "^9.1.0"
113
124
  },
114
125
  "engines": {
115
126
  "node": ">=24.16.0",
@@ -128,6 +139,10 @@
128
139
  "path": "dist/styles/style.css",
129
140
  "limit": "15 KB"
130
141
  },
142
+ {
143
+ "path": "dist/style.es.js",
144
+ "limit": "20 KB"
145
+ },
131
146
  {
132
147
  "path": "dist/styles/preset.css",
133
148
  "limit": "2 KB"
@@ -151,9 +166,9 @@
151
166
  "format:check": "vp fmt --check",
152
167
  "test": "exit 0",
153
168
  "test:ci": "exit 0",
169
+ "test:storybook": "test-storybook --includeTags shadow-smoke",
154
170
  "release:dry": "pnpm publish --dry-run --no-git-checks",
155
171
  "analyze": "pnpm build && open dist/stats.html",
156
- "size": "size-limit",
157
- "preview": "pnpm dlx serve . -p 4000"
172
+ "size": "size-limit"
158
173
  }
159
174
  }