@sveltia/ui 0.25.3 → 0.25.4

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.
@@ -4,6 +4,7 @@
4
4
  @see https://w3c.github.io/aria/#menuitem
5
5
  -->
6
6
  <script>
7
+ import { onMount } from 'svelte';
7
8
  import Button from '../button/button.svelte';
8
9
  import Icon from '../icon/icon.svelte';
9
10
  import Popup from '../util/popup.svelte';
@@ -25,6 +26,9 @@
25
26
  endIcon: _endIcon,
26
27
  chevronIcon,
27
28
  items,
29
+ onmouseenter,
30
+ onmouseleave,
31
+ onclick,
28
32
  onChange,
29
33
  onSelect,
30
34
  ...restProps
@@ -32,6 +36,7 @@
32
36
  } = $props();
33
37
 
34
38
  let isPopupOpen = $state(false);
39
+ let isPopupHovered = $state(false);
35
40
 
36
41
  /**
37
42
  * Reference to the `<button>` element.
@@ -39,7 +44,17 @@
39
44
  */
40
45
  let buttonElement = $state();
41
46
 
47
+ /**
48
+ * Reference to the `<button>` element.
49
+ * @type {HTMLDialogElement | undefined}
50
+ */
51
+ let dialogElement = $state();
52
+
42
53
  const hasItems = $derived(role === 'menuitem' && !!items);
54
+
55
+ onMount(() => {
56
+ dialogElement = buttonElement?.closest('dialog') ?? undefined;
57
+ });
43
58
  </script>
44
59
 
45
60
  <div role="none" class="sui menuitem {className}" {hidden}>
@@ -51,15 +66,34 @@
51
66
  {disabled}
52
67
  aria-haspopup={hasItems ? 'menu' : undefined}
53
68
  aria-expanded={hasItems ? isPopupOpen : undefined}
54
- onmouseenter={() => {
69
+ onmouseenter={(event) => {
55
70
  if (hasItems) {
56
- isPopupOpen = true;
71
+ window.setTimeout(() => {
72
+ isPopupOpen = true;
73
+ }, 200);
57
74
  }
75
+
76
+ onmouseenter?.(event);
58
77
  }}
59
- onmouseleave={() => {
78
+ onmouseleave={(event) => {
60
79
  if (hasItems) {
61
- isPopupOpen = false;
80
+ window.setTimeout(() => {
81
+ if (!isPopupHovered) {
82
+ isPopupOpen = false;
83
+ }
84
+ }, 200);
62
85
  }
86
+
87
+ onmouseleave?.(event);
88
+ }}
89
+ onclick={(event) => {
90
+ if (hasItems) {
91
+ event.preventDefault();
92
+ event.stopPropagation();
93
+ isPopupOpen = !isPopupOpen;
94
+ }
95
+
96
+ onclick?.(event);
63
97
  }}
64
98
  {onChange}
65
99
  {onSelect}
@@ -89,8 +123,14 @@
89
123
  {@render _endIcon?.()}
90
124
  {/snippet}
91
125
  </Button>
92
- {#if hasItems}
93
- <Popup anchor={buttonElement} position="right-top" bind:open={isPopupOpen}>
126
+ {#if hasItems && buttonElement && dialogElement}
127
+ <Popup
128
+ anchor={buttonElement}
129
+ parentDialogElement={dialogElement}
130
+ position="right-top"
131
+ bind:open={isPopupOpen}
132
+ bind:hovered={isPopupHovered}
133
+ >
94
134
  <Menu>
95
135
  {@render items?.()}
96
136
  </Menu>
@@ -13,12 +13,15 @@
13
13
  * @typedef {object} Props
14
14
  * @property {string} [class] - The `class` attribute on the content element.
15
15
  * @property {boolean} [open] - Whether to open the popup.
16
+ * @property {boolean} [hovered] - Whether the content is hovered.
16
17
  * @property {HTMLElement} [anchor] - A reference to the anchor element that opens the popup.
17
18
  * Typically a `<button>`.
18
19
  * @property {HTMLElement} [content] - A reference to the content element.
19
20
  * @property {import('../../typedefs').PopupPosition} [position] - Where to show the popup.
20
- * @property {HTMLElement} [positionBaseElement] - The base element of
21
- * {@link position}. If omitted, this will be {@link anchor}.
21
+ * @property {HTMLElement} [positionBaseElement] - The base element of {@link position}. If
22
+ * omitted, this will be {@link anchor}.
23
+ * @property {HTMLDialogElement} [parentDialogElement] - A reference to a dialog element that is
24
+ * already displayed. This should be provided for a nested popup.
22
25
  * @property {boolean} [touchOptimized] - Whether to show the popup at the center of the screen on
23
26
  * mobile/tablet and ignore the defined dropdown `position`.
24
27
  * @property {import('svelte').Snippet} [children] - Primary slot content.
@@ -32,12 +35,14 @@
32
35
  let {
33
36
  /* eslint-disable prefer-const */
34
37
  open = $bindable(false),
38
+ hovered = $bindable(false),
35
39
  content = $bindable(undefined),
36
40
  class: className,
37
41
  showBackdrop = false,
38
42
  anchor,
39
43
  position = 'bottom-left',
40
44
  positionBaseElement = undefined,
45
+ parentDialogElement = undefined,
41
46
  touchOptimized = false,
42
47
  children,
43
48
  onOpen,
@@ -77,6 +82,8 @@
77
82
  height: undefined,
78
83
  });
79
84
 
85
+ let hoveredTimeout = 0;
86
+
80
87
  /**
81
88
  * Initialize the popup.
82
89
  */
@@ -98,6 +105,13 @@
98
105
  initialized = true;
99
106
  };
100
107
 
108
+ $effect(() => {
109
+ if (parentDialogElement && !dialogElement && content) {
110
+ dialogElement = parentDialogElement;
111
+ dialogElement.append(content);
112
+ }
113
+ });
114
+
101
115
  $effect(() => {
102
116
  if (anchor && dialogElement && !initialized) {
103
117
  init();
@@ -111,38 +125,10 @@
111
125
  });
112
126
  </script>
113
127
 
114
- <Modal
115
- {...restProps}
116
- bind:dialog={dialogElement}
117
- role="none"
118
- class="popup"
119
- bind:open
120
- showBackdrop={showBackdrop ?? touch}
121
- lightDismiss={true}
122
- keepContent={true}
123
- onOpen={async (event) => {
124
- onOpen?.(event);
125
-
126
- await sleep(100);
127
-
128
- if (!content) {
129
- return;
130
- }
131
-
132
- const target = /** @type {HTMLElement} */ (
133
- content.querySelector('[tabindex]:not([aria-disabled="true"])')
134
- );
135
-
136
- if (target) {
137
- target.focus();
138
- } else {
139
- content.tabIndex = -1;
140
- content.focus();
141
- }
142
- }}
143
- >
128
+ {#snippet contentWrapper()}
144
129
  <div
145
130
  bind:this={content}
131
+ hidden={!open}
146
132
  role="none"
147
133
  class="content {className} {contentType}"
148
134
  class:touch
@@ -152,10 +138,63 @@
152
138
  style:max-width={$style.maxWidth}
153
139
  style:max-height={$style.height}
154
140
  style:visibility={$style.inset ? undefined : 'hidden'}
141
+ onmouseenter={() => {
142
+ hovered = true;
143
+
144
+ if (parentDialogElement) {
145
+ window.clearTimeout(hoveredTimeout);
146
+ }
147
+ }}
148
+ onmouseleave={() => {
149
+ hovered = false;
150
+
151
+ if (parentDialogElement) {
152
+ hoveredTimeout = window.setTimeout(() => {
153
+ open = false;
154
+ }, 200);
155
+ }
156
+ }}
155
157
  >
156
158
  {@render children?.()}
157
159
  </div>
158
- </Modal>
160
+ {/snippet}
161
+
162
+ {#if parentDialogElement}
163
+ {@render contentWrapper()}
164
+ {:else}
165
+ <Modal
166
+ {...restProps}
167
+ bind:dialog={dialogElement}
168
+ role="none"
169
+ class="popup"
170
+ bind:open
171
+ showBackdrop={showBackdrop ?? touch}
172
+ lightDismiss={true}
173
+ keepContent={true}
174
+ onOpen={async (event) => {
175
+ onOpen?.(event);
176
+
177
+ await sleep(100);
178
+
179
+ if (!content) {
180
+ return;
181
+ }
182
+
183
+ const target = /** @type {HTMLElement} */ (
184
+ content.querySelector('[tabindex]:not([aria-disabled="true"])')
185
+ );
186
+
187
+ if (target) {
188
+ target.focus();
189
+ } else {
190
+ content.tabIndex = -1;
191
+ content.focus();
192
+ }
193
+ }}
194
+ >
195
+ {@render contentWrapper()}
196
+ </Modal>
197
+ {/if}
159
198
 
160
199
  <style>.content {
161
200
  position: absolute;
@@ -13,6 +13,10 @@ declare const Popup: import("svelte").Component<import("../../typedefs").ModalPr
13
13
  * - Whether to open the popup.
14
14
  */
15
15
  open?: boolean | undefined;
16
+ /**
17
+ * - Whether the content is hovered.
18
+ */
19
+ hovered?: boolean | undefined;
16
20
  /**
17
21
  * - A reference to the anchor element that opens the popup.
18
22
  * Typically a `<button>`.
@@ -27,10 +31,15 @@ declare const Popup: import("svelte").Component<import("../../typedefs").ModalPr
27
31
  */
28
32
  position?: import("../../typedefs").PopupPosition | undefined;
29
33
  /**
30
- * - The base element of
31
- * {@link position}. If omitted, this will be {@link anchor}.
34
+ * - The base element of {@link position}. If
35
+ * omitted, this will be {@link anchor}.
32
36
  */
33
37
  positionBaseElement?: HTMLElement | undefined;
38
+ /**
39
+ * - A reference to a dialog element that is
40
+ * already displayed. This should be provided for a nested popup.
41
+ */
42
+ parentDialogElement?: HTMLDialogElement | undefined;
34
43
  /**
35
44
  * - Whether to show the popup at the center of the screen on
36
45
  * mobile/tablet and ignore the defined dropdown `position`.
@@ -48,7 +57,7 @@ declare const Popup: import("svelte").Component<import("../../typedefs").ModalPr
48
57
  * - Custom `Open` event handler.
49
58
  */
50
59
  onOpen?: ((event: CustomEvent) => void) | undefined;
51
- } & Record<string, any>, {}, "open" | "content">;
60
+ } & Record<string, any>, {}, "open" | "hovered" | "content">;
52
61
  type Props = {
53
62
  /**
54
63
  * - The `class` attribute on the content element.
@@ -58,6 +67,10 @@ type Props = {
58
67
  * - Whether to open the popup.
59
68
  */
60
69
  open?: boolean | undefined;
70
+ /**
71
+ * - Whether the content is hovered.
72
+ */
73
+ hovered?: boolean | undefined;
61
74
  /**
62
75
  * - A reference to the anchor element that opens the popup.
63
76
  * Typically a `<button>`.
@@ -72,10 +85,15 @@ type Props = {
72
85
  */
73
86
  position?: import("../../typedefs").PopupPosition | undefined;
74
87
  /**
75
- * - The base element of
76
- * {@link position}. If omitted, this will be {@link anchor}.
88
+ * - The base element of {@link position}. If
89
+ * omitted, this will be {@link anchor}.
77
90
  */
78
91
  positionBaseElement?: HTMLElement | undefined;
92
+ /**
93
+ * - A reference to a dialog element that is
94
+ * already displayed. This should be provided for a nested popup.
95
+ */
96
+ parentDialogElement?: HTMLDialogElement | undefined;
79
97
  /**
80
98
  * - Whether to show the popup at the center of the screen on
81
99
  * mobile/tablet and ignore the defined dropdown `position`.
@@ -89,11 +89,9 @@ class Popup {
89
89
  ? `${Math.round(intersectionRect.left)}px`
90
90
  : 'auto';
91
91
 
92
- const anchorPopup = /** @type {HTMLElement} */ (this.anchorElement.closest('.popup'));
93
-
94
92
  const style = {
95
93
  inset: [top, right, bottom, left].join(' '),
96
- zIndex: anchorPopup ? Number(anchorPopup.style.zIndex) + 1 : 1000,
94
+ zIndex: 1000,
97
95
  minWidth: `${Math.round(intersectionRect.width)}px`,
98
96
  maxWidth: position.endsWith('-left')
99
97
  ? `${Math.round(rootBounds.width - intersectionRect.left - 8)}px`
@@ -467,6 +467,18 @@ export type KeyboardEventHandlers = {
467
467
  onkeypress?: ((event: KeyboardEvent) => void) | undefined;
468
468
  };
469
469
  export type MouseEventHandlers = {
470
+ /**
471
+ * - `mouseenter` event handler.
472
+ */
473
+ onmouseenter?: ((event: MouseEvent) => void) | undefined;
474
+ /**
475
+ * - `mouseleave` event handler.
476
+ */
477
+ onmouseleave?: ((event: MouseEvent) => void) | undefined;
478
+ /**
479
+ * - `mouseover` event handler.
480
+ */
481
+ onmouseover?: ((event: MouseEvent) => void) | undefined;
470
482
  /**
471
483
  * - `mousedown` event handler.
472
484
  */
package/dist/typedefs.js CHANGED
@@ -164,6 +164,9 @@
164
164
 
165
165
  /**
166
166
  * @typedef {object} MouseEventHandlers
167
+ * @property {(event: MouseEvent) => void} [onmouseenter] - `mouseenter` event handler.
168
+ * @property {(event: MouseEvent) => void} [onmouseleave] - `mouseleave` event handler.
169
+ * @property {(event: MouseEvent) => void} [onmouseover] - `mouseover` event handler.
167
170
  * @property {(event: MouseEvent) => void} [onmousedown] - `mousedown` event handler.
168
171
  * @property {(event: MouseEvent) => void} [onmouseup] - `mouseup` event handler.
169
172
  * @property {(event: MouseEvent) => void} [onclick] - `click` event handler.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sveltia/ui",
3
- "version": "0.25.3",
3
+ "version": "0.25.4",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {
@@ -48,13 +48,13 @@
48
48
  "devDependencies": {
49
49
  "@playwright/test": "^1.51.0",
50
50
  "@sveltejs/adapter-auto": "^4.0.0",
51
- "@sveltejs/kit": "^2.18.0",
51
+ "@sveltejs/kit": "^2.19.0",
52
52
  "@sveltejs/package": "^2.3.10",
53
53
  "@sveltejs/vite-plugin-svelte": "5.0.3",
54
54
  "cspell": "^8.17.5",
55
55
  "eslint": "^8.57.1",
56
56
  "eslint-config-airbnb-base": "^15.0.0",
57
- "eslint-config-prettier": "^10.0.2",
57
+ "eslint-config-prettier": "^10.1.1",
58
58
  "eslint-plugin-import": "^2.31.0",
59
59
  "eslint-plugin-jsdoc": "^50.6.3",
60
60
  "eslint-plugin-svelte": "^2.46.1",
@@ -66,7 +66,7 @@
66
66
  "stylelint": "^16.15.0",
67
67
  "stylelint-config-recommended-scss": "^14.1.0",
68
68
  "stylelint-scss": "^6.11.1",
69
- "svelte": "5.22.5",
69
+ "svelte": "5.22.6",
70
70
  "svelte-check": "^4.1.5",
71
71
  "svelte-i18n": "^4.0.1",
72
72
  "svelte-preprocess": "^6.0.3",