@zylem/ui 0.2.0 → 0.8.8

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 (35) hide show
  1. package/dist/chunk-3TP2SNNW.js +1 -0
  2. package/dist/index.css +1 -1
  3. package/dist/index.d.ts +22 -2
  4. package/dist/index.js +1 -1
  5. package/dist/styles.css +1 -1
  6. package/dist/styles.js +1 -1
  7. package/package.json +11 -3
  8. package/src/components/Dialog/Dialog.css.ts +3 -2
  9. package/src/components/Dialog/Dialog.tsx +44 -20
  10. package/src/components/DropdownMenu/DropdownMenu.css.ts +1 -1
  11. package/src/components/DropdownMenu/DropdownMenu.tsx +47 -28
  12. package/src/components/ItemPicker/ItemPicker.css.ts +101 -0
  13. package/src/components/ItemPicker/ItemPicker.tsx +108 -0
  14. package/src/components/ItemPicker/filter-items.ts +75 -0
  15. package/src/components/MenuButton/MenuButton.css.ts +91 -0
  16. package/src/components/MenuButton/MenuButton.tsx +153 -0
  17. package/src/components/Property/Property.tsx +8 -6
  18. package/src/components/SearchInput/SearchInput.tsx +3 -0
  19. package/src/components/Select/Select.css.ts +1 -1
  20. package/src/components/Select/Select.tsx +17 -5
  21. package/src/components/ToolbarButton/ToolbarButton.tsx +17 -7
  22. package/src/components/Tooltip/Tooltip.css.ts +1 -1
  23. package/src/components/Tooltip/Tooltip.tsx +18 -6
  24. package/src/components/WindowControls/WindowControls.tsx +7 -5
  25. package/src/components/index.ts +27 -0
  26. package/src/global/detached-panel.css.ts +4 -1
  27. package/src/layers/LayerContext.tsx +132 -0
  28. package/src/layers/dismiss-guard.ts +81 -0
  29. package/src/layers/index.ts +27 -0
  30. package/src/layers/layer-tokens.css.ts +38 -0
  31. package/src/layers/resolve-mount.ts +48 -0
  32. package/src/layers/tiers.ts +53 -0
  33. package/src/styles.ts +3 -0
  34. package/src/theme.css.ts +7 -0
  35. package/dist/chunk-VGOOWBSO.js +0 -1
@@ -0,0 +1,153 @@
1
+ import { Popover as KPopover } from '@kobalte/core';
2
+ import { Show, createSignal, type JSX } from 'solid-js';
3
+ import { createDismissGuard, useLayer } from '../../layers';
4
+ import { Tooltip } from '../Tooltip/Tooltip';
5
+
6
+ export interface MenuButtonProps {
7
+ /** Accessible name, and the tooltip text unless `tooltip` overrides it. */
8
+ label: string;
9
+ /** Button face content, usually an icon. */
10
+ children: JSX.Element;
11
+ /** Panel content: a menu, a picker, anything. Rendered only while open. */
12
+ menu: (close: () => void) => JSX.Element;
13
+ /**
14
+ * Primary action for the face. Given one, the button splits: the face fires
15
+ * this and the chevron opens the menu. Without one, the whole button opens
16
+ * the menu and the chevron is only there to say so.
17
+ *
18
+ * Optional props here accept an explicit `undefined` so a caller compiled with
19
+ * `exactOptionalPropertyTypes` can decide the split at the call site.
20
+ */
21
+ onAction?: (() => void) | undefined;
22
+ /** Renders the face in its selected state. */
23
+ selected?: boolean | undefined;
24
+ disabled?: boolean | undefined;
25
+ /** Tooltip text; defaults to `label`. Pass `null` for no tooltip. */
26
+ tooltip?: string | null | undefined;
27
+ placement?:
28
+ | 'bottom-start'
29
+ | 'bottom-end'
30
+ | 'bottom'
31
+ | 'top-start'
32
+ | 'top-end'
33
+ | 'top'
34
+ | undefined;
35
+ /** Controlled open state. Omit to let the button own it. */
36
+ open?: boolean | undefined;
37
+ onOpenChange?: ((open: boolean) => void) | undefined;
38
+ class?: string | undefined;
39
+ }
40
+
41
+ /**
42
+ * Shown in both modes, because a chevron means "this has a menu" and that is
43
+ * true whether or not the face carries its own action.
44
+ */
45
+ const CHEVRON = '▾';
46
+
47
+ /**
48
+ * A toolbar button that owns a menu, optionally split.
49
+ *
50
+ * Split mode exists because choosing and doing are separate jobs: the face
51
+ * repeats the last choice and the chevron is there for picking a different one.
52
+ * Placing ten of the same thing is then one trip through the menu and ten
53
+ * clicks, rather than ten trips.
54
+ *
55
+ * The panel is layered at the `menu` tier and portaled out of the button's own
56
+ * subtree, so a toolbar that clips its overflow or sits in a lower stacking
57
+ * context cannot hide it.
58
+ */
59
+ export function MenuButton(props: MenuButtonProps) {
60
+ const [uncontrolledOpen, setUncontrolledOpen] = createSignal(false);
61
+ const layer = useLayer('menu');
62
+ const dismiss = createDismissGuard();
63
+
64
+ const isOpen = () => props.open ?? uncontrolledOpen();
65
+ const setOpen = (open: boolean) => {
66
+ setUncontrolledOpen(open);
67
+ props.onOpenChange?.(open);
68
+ };
69
+
70
+ const isSplit = () => props.onAction !== undefined;
71
+ const tooltipText = () =>
72
+ props.tooltip === null ? null : props.tooltip ?? props.label;
73
+
74
+ /** Wrap in a tooltip only when there is text for one. */
75
+ const described = (content: JSX.Element) => (
76
+ <Show when={tooltipText()} fallback={content} keyed>
77
+ {(text) => <Tooltip content={text}>{content}</Tooltip>}
78
+ </Show>
79
+ );
80
+
81
+ return (
82
+ <div
83
+ class={props.class ? `zylem-menu-button ${props.class}` : 'zylem-menu-button'}
84
+ data-split={isSplit() ? '' : undefined}
85
+ >
86
+ <KPopover.Root
87
+ open={isOpen()}
88
+ onOpenChange={setOpen}
89
+ placement={props.placement ?? 'bottom-start'}
90
+ >
91
+ <Show
92
+ when={isSplit()}
93
+ fallback={described(
94
+ <KPopover.Trigger
95
+ ref={layer.anchorRef}
96
+ class="zylem-toolbar-btn zylem-menu-button__face"
97
+ aria-label={props.label}
98
+ disabled={props.disabled}
99
+ data-selected={props.selected ? '' : undefined}
100
+ >
101
+ {props.children}
102
+ {/*
103
+ * Signage only: the button around it already opens the menu, so
104
+ * this must not read as a second control.
105
+ */}
106
+ <span
107
+ class="zylem-menu-button__chevron zylem-menu-button__chevron--static"
108
+ aria-hidden="true"
109
+ >
110
+ {CHEVRON}
111
+ </span>
112
+ </KPopover.Trigger>,
113
+ )}
114
+ >
115
+ {described(
116
+ <button
117
+ type="button"
118
+ ref={layer.anchorRef}
119
+ class="zylem-toolbar-btn zylem-menu-button__face"
120
+ aria-label={props.label}
121
+ disabled={props.disabled}
122
+ data-selected={props.selected ? '' : undefined}
123
+ onClick={() => props.onAction?.()}
124
+ >
125
+ {props.children}
126
+ </button>,
127
+ )}
128
+ <KPopover.Trigger
129
+ class="zylem-menu-button__chevron"
130
+ aria-label={`${props.label} options`}
131
+ disabled={props.disabled}
132
+ >
133
+ <span aria-hidden="true">{CHEVRON}</span>
134
+ </KPopover.Trigger>
135
+ </Show>
136
+
137
+ <Show when={layer.mount()} keyed>
138
+ {(mount) => (
139
+ <KPopover.Portal mount={mount}>
140
+ <KPopover.Content
141
+ ref={dismiss.ref}
142
+ class="zylem-menu-button__panel"
143
+ onInteractOutside={dismiss.onInteractOutside}
144
+ >
145
+ {props.menu(() => setOpen(false))}
146
+ </KPopover.Content>
147
+ </KPopover.Portal>
148
+ )}
149
+ </Show>
150
+ </KPopover.Root>
151
+ </div>
152
+ );
153
+ }
@@ -25,17 +25,19 @@ export function PropertyList(props: PropertyListProps) {
25
25
 
26
26
  export interface PropertyRowProps {
27
27
  label: string;
28
+ // Optional props accept an explicit `undefined` so callers compiled with
29
+ // `exactOptionalPropertyTypes` can forward their own optional props directly.
28
30
  /** The value to display. Can be a string, number, or JSX element. */
29
- value?: string | number | JSX.Element;
31
+ value?: string | number | JSX.Element | undefined;
30
32
  /** If true, treat string values as paths and truncate long ones. */
31
- isPath?: boolean;
33
+ isPath?: boolean | undefined;
32
34
  /** Maximum path segments to show (default: 3). */
33
- maxPathSegments?: number;
35
+ maxPathSegments?: number | undefined;
34
36
  /** Called with the full value when a truncated path is clicked. */
35
- onPathClick?: (fullValue: string) => void;
36
- valueClass?: string;
37
+ onPathClick?: ((fullValue: string) => void) | undefined;
38
+ valueClass?: string | undefined;
37
39
  /** Children can be used instead of value for complex content. */
38
- children?: JSX.Element;
40
+ children?: JSX.Element | undefined;
39
41
  }
40
42
 
41
43
  function truncatePath(
@@ -8,6 +8,8 @@ export interface SearchInputProps {
8
8
  class?: string;
9
9
  'aria-label'?: string;
10
10
  onKeyDown?: JSX.EventHandlerUnion<HTMLInputElement, KeyboardEvent>;
11
+ /** Handle on the input itself, for callers that need to focus it. */
12
+ ref?: (element: HTMLInputElement) => void;
11
13
  }
12
14
 
13
15
  /**
@@ -19,6 +21,7 @@ export function SearchInput(props: SearchInputProps) {
19
21
  <Search class="zylem-search-icon" />
20
22
  <input
21
23
  type="search"
24
+ ref={props.ref}
22
25
  class="zylem-field zylem-search-input"
23
26
  value={props.value ?? ''}
24
27
  placeholder={props.placeholder ?? 'Search...'}
@@ -31,7 +31,7 @@ globalStyle('.zylem-select-trigger[data-expanded] .zylem-select-icon', {
31
31
  });
32
32
 
33
33
  globalStyle('.zylem-select-content', {
34
- zIndex: 10000,
34
+ zIndex: vars.layers.menu,
35
35
  borderRadius: vars.radii.control,
36
36
  border: '1px solid rgba(97, 166, 232, 0.45)',
37
37
  background: vars.material.glassPanelDark,
@@ -1,5 +1,6 @@
1
1
  import { Select as KSelect } from '@kobalte/core';
2
2
  import { Show } from 'solid-js';
3
+ import { createDismissGuard, useLayer } from '../../layers';
3
4
 
4
5
  export interface SelectOption {
5
6
  value: string;
@@ -22,6 +23,8 @@ export interface SelectProps {
22
23
  * HyperGlass select: inset glass trigger opening a floating glass sheet.
23
24
  */
24
25
  export function Select(props: SelectProps) {
26
+ const layer = useLayer('menu');
27
+ const dismiss = createDismissGuard();
25
28
  const selected = () =>
26
29
  props.options.find((option) => option.value === props.value) ?? null;
27
30
 
@@ -50,6 +53,7 @@ export function Select(props: SelectProps) {
50
53
  <KSelect.Label class="zylem-field-label">{props.label}</KSelect.Label>
51
54
  </Show>
52
55
  <KSelect.Trigger
56
+ ref={layer.anchorRef}
53
57
  class="zylem-field zylem-select-trigger"
54
58
  aria-label={props.label}
55
59
  >
@@ -58,11 +62,19 @@ export function Select(props: SelectProps) {
58
62
  </KSelect.Value>
59
63
  <KSelect.Icon class="zylem-select-icon">▼</KSelect.Icon>
60
64
  </KSelect.Trigger>
61
- <KSelect.Portal>
62
- <KSelect.Content class="zylem-select-content">
63
- <KSelect.Listbox class="zylem-select-listbox zylem-scroll" />
64
- </KSelect.Content>
65
- </KSelect.Portal>
65
+ <Show when={layer.mount()} keyed>
66
+ {(mount) => (
67
+ <KSelect.Portal mount={mount}>
68
+ <KSelect.Content
69
+ ref={dismiss.ref}
70
+ class="zylem-select-content"
71
+ onInteractOutside={dismiss.onInteractOutside}
72
+ >
73
+ <KSelect.Listbox class="zylem-select-listbox zylem-scroll" />
74
+ </KSelect.Content>
75
+ </KSelect.Portal>
76
+ )}
77
+ </Show>
66
78
  </KSelect.Root>
67
79
  );
68
80
  }
@@ -1,5 +1,6 @@
1
1
  import { Button as KButton, Tooltip as KTooltip } from '@kobalte/core';
2
- import type { JSX } from 'solid-js';
2
+ import { Show, type JSX } from 'solid-js';
3
+ import { useLayer } from '../../layers';
3
4
 
4
5
  export interface ToolbarButtonProps {
5
6
  label: string;
@@ -13,11 +14,16 @@ export interface ToolbarButtonProps {
13
14
  /**
14
15
  * HyperGlass toolbar button: square glass icon button with a tooltip.
15
16
  * Selected state renders as a live-green jewel.
17
+ *
18
+ * The tooltip portals into the layer container for its own tree, so it stays
19
+ * styled when the toolbar lives inside a web component's shadow root.
16
20
  */
17
21
  export function ToolbarButton(props: ToolbarButtonProps) {
22
+ const layer = useLayer('tooltip');
23
+
18
24
  return (
19
25
  <KTooltip.Root openDelay={400}>
20
- <KTooltip.Trigger as="span">
26
+ <KTooltip.Trigger as="span" ref={layer.anchorRef}>
21
27
  <KButton.Root
22
28
  aria-label={props.label}
23
29
  onClick={props.onClick}
@@ -32,11 +38,15 @@ export function ToolbarButton(props: ToolbarButtonProps) {
32
38
  {props.children}
33
39
  </KButton.Root>
34
40
  </KTooltip.Trigger>
35
- <KTooltip.Portal>
36
- <KTooltip.Content class="zylem-tooltip">
37
- {props.label}
38
- </KTooltip.Content>
39
- </KTooltip.Portal>
41
+ <Show when={layer.mount()} keyed>
42
+ {(mount) => (
43
+ <KTooltip.Portal mount={mount}>
44
+ <KTooltip.Content class="zylem-tooltip">
45
+ {props.label}
46
+ </KTooltip.Content>
47
+ </KTooltip.Portal>
48
+ )}
49
+ </Show>
40
50
  </KTooltip.Root>
41
51
  );
42
52
  }
@@ -8,7 +8,7 @@ const enter = keyframes({
8
8
 
9
9
  globalStyle('.zylem-tooltip', {
10
10
  position: 'relative',
11
- zIndex: 10050,
11
+ zIndex: vars.layers.tooltip,
12
12
  maxWidth: '260px',
13
13
  padding: `${vars.spacing.xs} ${vars.spacing.sm}`,
14
14
  borderRadius: vars.radii.control,
@@ -1,5 +1,6 @@
1
1
  import { Tooltip as KTooltip } from '@kobalte/core';
2
- import type { JSX } from 'solid-js';
2
+ import { Show, type JSX } from 'solid-js';
3
+ import { useLayer } from '../../layers';
3
4
 
4
5
  export interface TooltipProps {
5
6
  /** Tooltip body. */
@@ -13,21 +14,32 @@ export interface TooltipProps {
13
14
  /**
14
15
  * HyperGlass tooltip: small floating glass sheet, portaled above page chrome
15
16
  * so it is not trapped by local stacking contexts (grids, panels, etc.).
17
+ *
18
+ * The portal target is resolved from the trigger rather than assumed to be
19
+ * `document.body`, so a tooltip inside a web component's shadow root still
20
+ * picks up the styles injected there.
16
21
  */
17
22
  export function Tooltip(props: TooltipProps) {
23
+ const layer = useLayer('tooltip');
24
+
18
25
  return (
19
26
  <KTooltip.Root placement={props.placement} openDelay={props.openDelay ?? 400}>
20
27
  <KTooltip.Trigger
21
28
  as="span"
29
+ ref={layer.anchorRef}
22
30
  style={{ display: 'flex', width: '100%', height: '100%', 'min-width': 0 }}
23
31
  >
24
32
  {props.children}
25
33
  </KTooltip.Trigger>
26
- <KTooltip.Portal>
27
- <KTooltip.Content class="zylem-tooltip">
28
- {props.content}
29
- </KTooltip.Content>
30
- </KTooltip.Portal>
34
+ <Show when={layer.mount()}>
35
+ {(mount) => (
36
+ <KTooltip.Portal mount={mount()}>
37
+ <KTooltip.Content class="zylem-tooltip">
38
+ {props.content}
39
+ </KTooltip.Content>
40
+ </KTooltip.Portal>
41
+ )}
42
+ </Show>
31
43
  </KTooltip.Root>
32
44
  );
33
45
  }
@@ -4,14 +4,16 @@ import PanelBottomOpen from 'lucide-solid/icons/panel-bottom-open';
4
4
  import X from 'lucide-solid/icons/x';
5
5
 
6
6
  export interface WindowControlsProps {
7
- onClose?: () => void;
8
- onCollapse?: () => void;
7
+ // Optional props accept an explicit `undefined` so callers compiled with
8
+ // `exactOptionalPropertyTypes` can forward their own optional props directly.
9
+ onClose?: (() => void) | undefined;
10
+ onCollapse?: (() => void) | undefined;
9
11
  /** Show the "expand" collapse icon when the panel is collapsed. */
10
- collapsed?: boolean;
12
+ collapsed?: boolean | undefined;
11
13
  /** Accessible label/title for the close button (default "Close panel"). */
12
- closeLabel?: string;
14
+ closeLabel?: string | undefined;
13
15
  /** Forwarded as data-testid on the close button. */
14
- closeTestId?: string;
16
+ closeTestId?: string | undefined;
15
17
  }
16
18
 
17
19
  /**
@@ -5,6 +5,24 @@
5
5
  * vanilla-extract recipes/styles driven by the Zylem token system.
6
6
  */
7
7
 
8
+ // Layering. Overlays here use it by default; consumers need it only to scope a
9
+ // subtree (a modal) or to layer their own chrome on the same ladder.
10
+ export {
11
+ LayerProvider,
12
+ LayerScope,
13
+ useLayer,
14
+ resolveLayerRoot,
15
+ ensureLayerContainer,
16
+ createDismissGuard,
17
+ LAYER_PORTAL_CLASS,
18
+ type DismissalEvent,
19
+ type DismissGuard,
20
+ type LayerProviderProps,
21
+ type LayerScopeProps,
22
+ type LayerTier,
23
+ type ResolvedLayer,
24
+ } from '../layers';
25
+
8
26
  // Primitives
9
27
  export { Button, type ButtonProps, type ButtonVariant, type ButtonSize } from './Button/Button';
10
28
  export { ToolbarButton, type ToolbarButtonProps } from './ToolbarButton/ToolbarButton';
@@ -64,6 +82,15 @@ export {
64
82
  type SidebarItemProps,
65
83
  } from './Sidebar/Sidebar';
66
84
  export { Card, type CardProps } from './Card/Card';
85
+ export { MenuButton, type MenuButtonProps } from './MenuButton/MenuButton';
86
+ export { ItemPicker, type ItemPickerProps } from './ItemPicker/ItemPicker';
87
+ export {
88
+ filterItems,
89
+ groupItems,
90
+ UNGROUPED_LABEL,
91
+ type PickerItem,
92
+ type PickerGroup,
93
+ } from './ItemPicker/filter-items';
67
94
  export { SearchInput, type SearchInputProps } from './SearchInput/SearchInput';
68
95
  export { EmptyState, type EmptyStateProps } from './EmptyState/EmptyState';
69
96
  export { ScrollArea, type ScrollAreaProps } from './ScrollArea/ScrollArea';
@@ -74,7 +74,10 @@ globalStyle('.accordion-drag-ghost', {
74
74
  backdropFilter: `blur(${vars.effects.blurSm})`,
75
75
  boxShadow: `0 8px 24px rgba(0, 0, 0, 0.5), ${vars.effects.glowDanger}`,
76
76
  position: 'fixed',
77
- zIndex: 9999,
77
+ // Top of the panel tier: the ghost follows the cursor over every panel,
78
+ // including the one it was torn from, but is still page furniture rather than
79
+ // an overlay, so it stays under modals and menus.
80
+ zIndex: `calc(${vars.layers.panel} + 900)`,
78
81
  pointerEvents: 'none',
79
82
  });
80
83
 
@@ -0,0 +1,132 @@
1
+ import {
2
+ createContext,
3
+ createMemo,
4
+ createSignal,
5
+ onMount,
6
+ useContext,
7
+ type Accessor,
8
+ type JSX,
9
+ } from 'solid-js';
10
+ import { ensureLayerContainer, LAYER_PORTAL_CLASS } from './resolve-mount';
11
+ import { layerTierVars, type LayerTier } from './tiers';
12
+
13
+ export type { LayerTier };
14
+
15
+ interface LayerContextValue {
16
+ container: Accessor<HTMLElement | undefined>;
17
+ }
18
+
19
+ const LayerCtx = createContext<LayerContextValue>();
20
+
21
+ export interface LayerProviderProps {
22
+ children: JSX.Element;
23
+ }
24
+
25
+ /**
26
+ * Root of the layer system: resolves the tree the app lives in — shadow root or
27
+ * document — and owns the container overlays portal into.
28
+ *
29
+ * Optional. `useLayer` resolves a container by itself when no provider is
30
+ * mounted, so a page with one `Tooltip` needs no setup. It earns its keep when an
31
+ * app spans several trees, such as two web components on one page, by pinning
32
+ * each subtree to the container in its own tree.
33
+ */
34
+ export function LayerProvider(props: LayerProviderProps) {
35
+ const [container, setContainer] = createSignal<HTMLElement>();
36
+ let markerRef: HTMLSpanElement | undefined;
37
+
38
+ // Deferred to mount so `getRootNode()` can see an enclosing shadow root.
39
+ onMount(() => setContainer(ensureLayerContainer(markerRef)));
40
+
41
+ return (
42
+ <LayerCtx.Provider value={{ container }}>
43
+ <span ref={markerRef} style={{ display: 'none' }} aria-hidden="true" />
44
+ {props.children}
45
+ </LayerCtx.Provider>
46
+ );
47
+ }
48
+
49
+ export interface LayerScopeProps {
50
+ children: JSX.Element;
51
+ class?: string | undefined;
52
+ style?: JSX.CSSProperties | undefined;
53
+ }
54
+
55
+ /**
56
+ * A subtree that layers independently of the rest of the page.
57
+ *
58
+ * Two things make that work: the scope is a stacking context, so its
59
+ * descendants' `z-index` values are only compared with each other and none of
60
+ * them can paint above anything outside; and the scope owns the portal container
61
+ * its descendants mount into, so overlays opened inside it stay inside it.
62
+ *
63
+ * A dialog wraps its content in one, so a tooltip opened within the dialog floats
64
+ * over the dialog body without also floating over a menu belonging to the page.
65
+ */
66
+ export function LayerScope(props: LayerScopeProps) {
67
+ const [container, setContainer] = createSignal<HTMLElement>();
68
+
69
+ return (
70
+ <div
71
+ class={props.class ? `zylem-layer-scope ${props.class}` : 'zylem-layer-scope'}
72
+ style={props.style}
73
+ >
74
+ <LayerCtx.Provider value={{ container }}>{props.children}</LayerCtx.Provider>
75
+ <div class={LAYER_PORTAL_CLASS} ref={setContainer} />
76
+ </div>
77
+ );
78
+ }
79
+
80
+ export interface ResolvedLayer {
81
+ /** Where to portal. `undefined` until mounted, so callers gate on it. */
82
+ mount: Accessor<HTMLElement | undefined>;
83
+ /** Value for the overlay's `z-index`. */
84
+ zIndex: Accessor<string>;
85
+ /**
86
+ * Attach to any element inside the component. Lets the tree be resolved
87
+ * without a `LayerProvider`, which is what keeps overlays styled inside a
88
+ * shadow root. Ignored when a provider is present.
89
+ */
90
+ anchorRef: (element: Element) => void;
91
+ }
92
+
93
+ /**
94
+ * Resolve a tier to a portal target and a `z-index`.
95
+ *
96
+ * @param rank Offset within the tier, for ordering peers — raising a clicked
97
+ * window above its siblings, say. Bands are 1000 apart, so ranks below that stay
98
+ * inside their tier. The offset means the same thing inside a `LayerScope` as
99
+ * out, so a panel can be lifted above a scope's tooltips while staying under its
100
+ * menus.
101
+ */
102
+ export function useLayer(
103
+ tier: LayerTier,
104
+ rank: Accessor<number> | number = 0,
105
+ ): ResolvedLayer {
106
+ const context = useContext(LayerCtx);
107
+ const [fallback, setFallback] = createSignal<HTMLElement>();
108
+ let anchor: Element | undefined;
109
+
110
+ if (!context) {
111
+ // Runs after the ref below has been assigned and the element is in the
112
+ // document, which `getRootNode()` needs. With no anchor attached this
113
+ // still resolves, just to the document body.
114
+ onMount(() => setFallback(ensureLayerContainer(anchor)));
115
+ }
116
+
117
+ const zIndex = createMemo(() => {
118
+ const offset = typeof rank === 'function' ? rank() : rank;
119
+ const token = layerTierVars[tier];
120
+ // Left as a `calc` over the custom property rather than resolved here, so
121
+ // the cascade stays in charge of the tier's actual value.
122
+ return offset ? `calc(${token} + ${offset})` : token;
123
+ });
124
+
125
+ return {
126
+ mount: context ? context.container : fallback,
127
+ zIndex,
128
+ anchorRef: (element: Element) => {
129
+ anchor = element;
130
+ },
131
+ };
132
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Keeps a dismissable overlay open when the interaction that "left" it never did.
3
+ *
4
+ * Kobalte decides an interaction is outside a layer by reading `event.target` in a
5
+ * capture-phase listener on `document`. Inside a shadow root that target has been
6
+ * retargeted to the host element, so the check asks whether the panel contains the
7
+ * whole web component — which it never does. Every pointerdown in the tree then
8
+ * reads as an outside click and the overlay closes on pointerdown, before the
9
+ * click can reach whatever was pressed. The same listener serves `focusin`, so
10
+ * focusing a field inside the panel closes it too.
11
+ *
12
+ * The original event survives on the dismissal event's `detail`, and its
13
+ * `composedPath()` still crosses the shadow boundary, so it can name the element
14
+ * actually pressed. Preventing the event cancels only the dismissal — a genuine
15
+ * outside interaction is left alone and still closes the overlay.
16
+ */
17
+
18
+ /**
19
+ * The shape of Kobalte's interact-outside events.
20
+ *
21
+ * Declared structurally rather than imported: the type is not re-exported from
22
+ * `@kobalte/core`'s entry point, and reaching into the package's internals to
23
+ * borrow it would tie this to their file layout.
24
+ */
25
+ export interface DismissalEvent {
26
+ detail: { originalEvent: Event };
27
+ preventDefault: () => void;
28
+ }
29
+
30
+ export interface DismissGuard {
31
+ /** Attach to the overlay's content element. */
32
+ ref: (element: HTMLElement) => void;
33
+ /** Pass to the content's `onInteractOutside`. */
34
+ onInteractOutside: (event: DismissalEvent) => void;
35
+ }
36
+
37
+ /** Whether a finished event's coordinates fall within `content`. */
38
+ function hitByPoint(event: Event, content: HTMLElement): boolean {
39
+ if (!('clientX' in event) || !('clientY' in event)) return false;
40
+ const { clientX, clientY } = event as MouseEvent;
41
+
42
+ const rect = content.getBoundingClientRect();
43
+ return (
44
+ clientX >= rect.left
45
+ && clientX <= rect.right
46
+ && clientY >= rect.top
47
+ && clientY <= rect.bottom
48
+ );
49
+ }
50
+
51
+ function isInside(original: Event, content: HTMLElement): boolean {
52
+ const path = original.composedPath();
53
+ if (path.length > 0) return path.includes(content);
54
+
55
+ // Touch takes a slower road: Kobalte defers the check to the following
56
+ // `click`, by which point the pointerdown has finished dispatching and its
57
+ // composed path has been emptied. Geometry is what is left to go on.
58
+ return hitByPoint(original, content);
59
+ }
60
+
61
+ /**
62
+ * Guard one overlay.
63
+ *
64
+ * Per-overlay rather than global because the answer depends on which content
65
+ * element is asking: a menu nested inside a dialog has to be able to dismiss
66
+ * without also dismissing the dialog around it.
67
+ */
68
+ export function createDismissGuard(): DismissGuard {
69
+ let content: HTMLElement | undefined;
70
+
71
+ return {
72
+ ref: (element: HTMLElement) => {
73
+ content = element;
74
+ },
75
+ onInteractOutside: (event: DismissalEvent) => {
76
+ if (!content) return;
77
+ if (!isInside(event.detail.originalEvent, content)) return;
78
+ event.preventDefault();
79
+ },
80
+ };
81
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Layering and portal system.
3
+ *
4
+ * Overlays read their `z-index` from a named tier instead of picking a number,
5
+ * and portal into a container resolved from the tree they were opened in rather
6
+ * than assuming `document.body`. See {@link LayerScope} for nesting.
7
+ */
8
+
9
+ export {
10
+ LayerProvider,
11
+ LayerScope,
12
+ useLayer,
13
+ type LayerProviderProps,
14
+ type LayerScopeProps,
15
+ type ResolvedLayer,
16
+ } from './LayerContext';
17
+ export {
18
+ createDismissGuard,
19
+ type DismissalEvent,
20
+ type DismissGuard,
21
+ } from './dismiss-guard';
22
+ export {
23
+ ensureLayerContainer,
24
+ resolveLayerRoot,
25
+ LAYER_PORTAL_CLASS,
26
+ } from './resolve-mount';
27
+ export { layerTierVars, type LayerTier } from './tiers';