@sveltia/ui 0.25.3 → 0.25.5

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,
@@ -68,7 +73,7 @@
68
73
  let contentType = $state();
69
74
  /**
70
75
  * Style to be applied to the content.
71
- * @type {import('svelte/store').Writable<any>}
76
+ * @type {import('svelte/store').Writable<Record<string, any>>}
72
77
  */
73
78
  let style = writable({
74
79
  inset: undefined,
@@ -77,20 +82,21 @@
77
82
  height: undefined,
78
83
  });
79
84
 
85
+ /**
86
+ * @type {{ style: import('svelte/store').Writable<Record<string, any>>, open:
87
+ * import('svelte/store').Writable<boolean>, checkPosition: () => void } | undefined}
88
+ */
89
+ let popupInstance = undefined;
90
+ let hoveredTimeout = 0;
91
+
80
92
  /**
81
93
  * Initialize the popup.
82
94
  */
83
95
  const init = () => {
84
- let openStore = writable(false);
85
-
86
- ({ style, open: openStore } = activatePopup(
87
- anchor,
88
- dialogElement,
89
- position,
90
- positionBaseElement,
91
- ));
96
+ popupInstance = activatePopup(anchor, dialogElement, position, positionBaseElement);
92
97
 
93
- openStore.subscribe((_open) => {
98
+ style = popupInstance.style;
99
+ popupInstance.open.subscribe((_open) => {
94
100
  open = _open;
95
101
  });
96
102
 
@@ -98,12 +104,25 @@
98
104
  initialized = true;
99
105
  };
100
106
 
107
+ $effect(() => {
108
+ if (parentDialogElement && !dialogElement && content) {
109
+ dialogElement = parentDialogElement;
110
+ dialogElement.append(content);
111
+ }
112
+ });
113
+
101
114
  $effect(() => {
102
115
  if (anchor && dialogElement && !initialized) {
103
116
  init();
104
117
  }
105
118
  });
106
119
 
120
+ $effect(() => {
121
+ if (parentDialogElement && open) {
122
+ popupInstance?.checkPosition();
123
+ }
124
+ });
125
+
107
126
  const touch = $derived(touchOptimized && touchEnabled);
108
127
 
109
128
  onMount(() => {
@@ -111,38 +130,10 @@
111
130
  });
112
131
  </script>
113
132
 
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
- >
133
+ {#snippet contentWrapper()}
144
134
  <div
145
135
  bind:this={content}
136
+ hidden={!open}
146
137
  role="none"
147
138
  class="content {className} {contentType}"
148
139
  class:touch
@@ -152,10 +143,63 @@
152
143
  style:max-width={$style.maxWidth}
153
144
  style:max-height={$style.height}
154
145
  style:visibility={$style.inset ? undefined : 'hidden'}
146
+ onmouseenter={() => {
147
+ hovered = true;
148
+
149
+ if (parentDialogElement) {
150
+ window.clearTimeout(hoveredTimeout);
151
+ }
152
+ }}
153
+ onmouseleave={() => {
154
+ hovered = false;
155
+
156
+ if (parentDialogElement) {
157
+ hoveredTimeout = window.setTimeout(() => {
158
+ open = false;
159
+ }, 200);
160
+ }
161
+ }}
155
162
  >
156
163
  {@render children?.()}
157
164
  </div>
158
- </Modal>
165
+ {/snippet}
166
+
167
+ {#if parentDialogElement}
168
+ {@render contentWrapper()}
169
+ {:else}
170
+ <Modal
171
+ {...restProps}
172
+ bind:dialog={dialogElement}
173
+ role="none"
174
+ class="popup"
175
+ bind:open
176
+ showBackdrop={showBackdrop ?? touch}
177
+ lightDismiss={true}
178
+ keepContent={true}
179
+ onOpen={async (event) => {
180
+ onOpen?.(event);
181
+
182
+ await sleep(100);
183
+
184
+ if (!content) {
185
+ return;
186
+ }
187
+
188
+ const target = /** @type {HTMLElement} */ (
189
+ content.querySelector('[tabindex]:not([aria-disabled="true"])')
190
+ );
191
+
192
+ if (target) {
193
+ target.focus();
194
+ } else {
195
+ content.tabIndex = -1;
196
+ content.focus();
197
+ }
198
+ }}
199
+ >
200
+ {@render contentWrapper()}
201
+ </Modal>
202
+ {/if}
159
203
 
160
204
  <style>.content {
161
205
  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.5",
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",