@miozu/jera 0.5.0 → 0.6.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miozu/jera",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Zero-dependency, AI-first component library for Svelte 5",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -1,7 +1,8 @@
1
1
  <!--
2
2
  @component Toast
3
3
 
4
- A toast notification system with stacking, auto-dismiss, and animations.
4
+ A toast notification system using native popover for top-layer rendering.
5
+ Provides stacking, auto-dismiss, and animations without z-index conflicts.
5
6
 
6
7
  @example
7
8
  // In your root layout
@@ -86,10 +87,11 @@
86
87
 
87
88
  <script>
88
89
  import { cn } from '../../utils/cn.svelte.js';
89
- import { portal } from '../../actions/index.js';
90
90
 
91
91
  const toast = getToastContext();
92
92
 
93
+ let containerEl = $state(null);
94
+
93
95
  const icons = {
94
96
  info: `<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>`,
95
97
  success: `<circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/>`,
@@ -105,52 +107,82 @@
105
107
  'bottom-center': 'toast-bottom-center',
106
108
  'bottom-right': 'toast-bottom-right'
107
109
  }[toast.position]);
110
+
111
+ // Show/hide popover based on toast count
112
+ $effect(() => {
113
+ if (!containerEl) return;
114
+
115
+ if (toast.toasts.length > 0 && !containerEl.matches(':popover-open')) {
116
+ containerEl.showPopover();
117
+ } else if (toast.toasts.length === 0 && containerEl.matches(':popover-open')) {
118
+ containerEl.hidePopover();
119
+ }
120
+ });
108
121
  </script>
109
122
 
110
- {#if toast.toasts.length > 0}
111
- <div use:portal class={cn('toast-container', positionClass)} role="region" aria-label="Notifications">
112
- {#each toast.toasts as item (item.id)}
113
- {@const remaining = item.duration - (Date.now() - item.createdAt)}
114
- <div
115
- class={cn('toast-item', `toast-${item.type}`)}
116
- role="alert"
117
- aria-live="polite"
118
- onmouseenter={() => toast.pause(item.id)}
119
- onmouseleave={() => toast.resume(item.id)}
120
- >
121
- <span class="toast-icon">
122
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
123
- {@html icons[item.type]}
124
- </svg>
125
- </span>
126
- <div class="toast-content">
127
- {#if item.title}
128
- <p class="toast-title">{item.title}</p>
129
- {/if}
130
- <p class={cn('toast-message', item.title && 'has-title')}>{item.message}</p>
131
- </div>
132
- <button type="button" class="toast-close" onclick={() => toast.dismiss(item.id)} aria-label="Dismiss">
133
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
134
- <path d="M18 6 6 18" /><path d="m6 6 12 12" />
135
- </svg>
136
- </button>
137
- {#if item.duration > 0 && !item.pausedAt}
138
- {@const _ = setTimeout(() => toast.dismiss(item.id), remaining)}
123
+ <!--
124
+ Using popover="manual" for:
125
+ - Automatic top-layer placement (no z-index wars)
126
+ - No portal action needed
127
+ - Native browser rendering optimization
128
+ -->
129
+ <div
130
+ bind:this={containerEl}
131
+ popover="manual"
132
+ class={cn('toast-container', positionClass)}
133
+ role="region"
134
+ aria-label="Notifications"
135
+ >
136
+ {#each toast.toasts as item (item.id)}
137
+ {@const remaining = item.duration - (Date.now() - item.createdAt)}
138
+ <div
139
+ class={cn('toast-item', `toast-${item.type}`)}
140
+ role="alert"
141
+ aria-live="polite"
142
+ onmouseenter={() => toast.pause(item.id)}
143
+ onmouseleave={() => toast.resume(item.id)}
144
+ >
145
+ <span class="toast-icon">
146
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
147
+ {@html icons[item.type]}
148
+ </svg>
149
+ </span>
150
+ <div class="toast-content">
151
+ {#if item.title}
152
+ <p class="toast-title">{item.title}</p>
139
153
  {/if}
154
+ <p class={cn('toast-message', item.title && 'has-title')}>{item.message}</p>
140
155
  </div>
141
- {/each}
142
- </div>
143
- {/if}
156
+ <button type="button" class="toast-close" onclick={() => toast.dismiss(item.id)} aria-label="Dismiss">
157
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
158
+ <path d="M18 6 6 18" /><path d="m6 6 12 12" />
159
+ </svg>
160
+ </button>
161
+ {#if item.duration > 0 && !item.pausedAt}
162
+ {@const _ = setTimeout(() => toast.dismiss(item.id), remaining)}
163
+ {/if}
164
+ </div>
165
+ {/each}
166
+ </div>
144
167
 
145
168
  <style>
169
+ /* Popover container - in top-layer, no z-index needed */
146
170
  .toast-container {
147
171
  position: fixed;
148
- z-index: 9999;
149
172
  display: flex;
150
173
  gap: 0.5rem;
151
174
  pointer-events: none;
175
+ /* Reset popover defaults */
176
+ border: none;
177
+ background: transparent;
178
+ padding: 0;
179
+ margin: 0;
180
+ overflow: visible;
181
+ /* Remove default popover positioning */
182
+ inset: unset;
152
183
  }
153
184
 
185
+ /* Position variants */
154
186
  .toast-top-left { top: 1rem; left: 1rem; flex-direction: column; }
155
187
  .toast-top-center { top: 1rem; left: 50%; transform: translateX(-50%); flex-direction: column; }
156
188
  .toast-top-right { top: 1rem; right: 1rem; flex-direction: column; }
@@ -1,7 +1,8 @@
1
1
  <!--
2
2
  @component Modal
3
3
 
4
- A flexible modal dialog component with backdrop, focus trap, and escape key support.
4
+ A flexible modal dialog component using native <dialog> element.
5
+ Provides built-in focus trap, ESC handling, backdrop, and accessibility.
5
6
 
6
7
  @example
7
8
  <Modal bind:open={showModal} title="Confirm Action">
@@ -13,9 +14,6 @@
13
14
  </Modal>
14
15
  -->
15
16
  <script>
16
- import { focusTrap, escapeKey, portal } from '../../actions/index.js';
17
- import { cv } from '../../utils/cn.svelte.js';
18
-
19
17
  let {
20
18
  open = $bindable(false),
21
19
  title = '',
@@ -31,6 +29,8 @@
31
29
  class: className = ''
32
30
  } = $props();
33
31
 
32
+ let dialogEl = $state(null);
33
+
34
34
  // Variant styles for the icon container
35
35
  const iconVariants = {
36
36
  default: { bg: 'var(--color-base02)', color: 'var(--color-base05)' },
@@ -42,118 +42,170 @@
42
42
 
43
43
  const iconStyle = $derived(iconVariants[variant] || iconVariants.default);
44
44
 
45
- function close() {
45
+ // Sync open state with native dialog
46
+ $effect(() => {
47
+ if (!dialogEl) return;
48
+
49
+ if (open && !dialogEl.open) {
50
+ dialogEl.showModal();
51
+ } else if (!open && dialogEl.open) {
52
+ dialogEl.close();
53
+ }
54
+ });
55
+
56
+ // Handle native close event (ESC key, form[method=dialog], etc.)
57
+ function handleClose() {
46
58
  open = false;
47
59
  onclose();
48
60
  }
49
61
 
62
+ // Handle cancel event (ESC key) - can be prevented
63
+ function handleCancel(e) {
64
+ if (!closeOnEscape) {
65
+ e.preventDefault();
66
+ }
67
+ }
68
+
69
+ // Handle backdrop click (click on dialog element itself, not content)
50
70
  function handleBackdropClick(e) {
51
- if (closeOnBackdrop && e.target === e.currentTarget) {
52
- close();
71
+ if (closeOnBackdrop && e.target === dialogEl) {
72
+ dialogEl.close();
53
73
  }
54
74
  }
55
75
 
56
- function handleEscape() {
57
- if (closeOnEscape) {
58
- close();
76
+ function close() {
77
+ if (dialogEl?.open) {
78
+ dialogEl.close();
59
79
  }
60
80
  }
61
81
  </script>
62
82
 
63
- {#if open}
64
- <div
65
- class="modal-backdrop"
66
- onclick={handleBackdropClick}
67
- role="dialog"
68
- aria-modal="true"
69
- aria-labelledby={title ? 'modal-title' : undefined}
70
- use:portal={'body'}
71
- use:focusTrap={{ enabled: open }}
72
- use:escapeKey={handleEscape}
73
- >
74
- <div class="modal modal-{size} {className}">
75
- <!-- Close button -->
76
- {#if showClose}
77
- <button
78
- class="modal-close"
79
- onclick={close}
80
- aria-label="Close modal"
81
- type="button"
82
- >
83
- <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
84
- <line x1="18" y1="6" x2="6" y2="18"></line>
85
- <line x1="6" y1="6" x2="18" y2="18"></line>
86
- </svg>
87
- </button>
88
- {/if}
89
-
90
- <!-- Content -->
91
- <div class="modal-content">
92
- {#if icon || title}
93
- <div class="modal-header">
94
- {#if icon}
95
- <div class="modal-icon" style="background: {iconStyle.bg}; color: {iconStyle.color};">
96
- {@render icon()}
97
- </div>
98
- {/if}
83
+ <dialog
84
+ bind:this={dialogEl}
85
+ class="modal modal-{size} {className}"
86
+ aria-labelledby={title ? 'modal-title' : undefined}
87
+ onclose={handleClose}
88
+ oncancel={handleCancel}
89
+ onclick={handleBackdropClick}
90
+ >
91
+ <!-- Close button -->
92
+ {#if showClose}
93
+ <button
94
+ class="modal-close"
95
+ onclick={close}
96
+ aria-label="Close modal"
97
+ type="button"
98
+ >
99
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
100
+ <line x1="18" y1="6" x2="6" y2="18"></line>
101
+ <line x1="6" y1="6" x2="18" y2="18"></line>
102
+ </svg>
103
+ </button>
104
+ {/if}
99
105
 
100
- {#if title || children}
101
- <div class="modal-text">
102
- {#if title}
103
- <h3 id="modal-title" class="modal-title">{title}</h3>
104
- {/if}
105
- {#if children}
106
- <div class="modal-body">
107
- {@render children()}
108
- </div>
109
- {/if}
110
- </div>
111
- {/if}
112
- </div>
113
- {:else if children}
114
- <div class="modal-body">
115
- {@render children()}
106
+ <!-- Content -->
107
+ <div class="modal-content">
108
+ {#if icon || title}
109
+ <div class="modal-header">
110
+ {#if icon}
111
+ <div class="modal-icon" style="background: {iconStyle.bg}; color: {iconStyle.color};">
112
+ {@render icon()}
116
113
  </div>
117
114
  {/if}
118
115
 
119
- {#if footer}
120
- <div class="modal-footer">
121
- {@render footer()}
116
+ {#if title || children}
117
+ <div class="modal-text">
118
+ {#if title}
119
+ <h3 id="modal-title" class="modal-title">{title}</h3>
120
+ {/if}
121
+ {#if children}
122
+ <div class="modal-body">
123
+ {@render children()}
124
+ </div>
125
+ {/if}
122
126
  </div>
123
127
  {/if}
124
128
  </div>
125
- </div>
129
+ {:else if children}
130
+ <div class="modal-body">
131
+ {@render children()}
132
+ </div>
133
+ {/if}
134
+
135
+ {#if footer}
136
+ <div class="modal-footer">
137
+ {@render footer()}
138
+ </div>
139
+ {/if}
126
140
  </div>
127
- {/if}
141
+ </dialog>
128
142
 
129
143
  <style>
130
- .modal-backdrop {
144
+ /* Native dialog element - automatically in top-layer */
145
+ dialog.modal {
131
146
  position: fixed;
132
- inset: 0;
133
- z-index: 100;
134
- display: flex;
135
- align-items: center;
136
- justify-content: center;
137
- background: color-mix(in srgb, var(--color-base00) 80%, transparent);
138
- backdrop-filter: blur(4px);
139
- }
140
-
141
- .modal {
142
- position: relative;
143
- background: var(--color-base01);
147
+ border: none;
144
148
  border-radius: 0.75rem;
149
+ background: var(--color-base01);
145
150
  box-shadow: var(--shadow-2xl);
146
151
  border: 1px solid var(--color-base03);
147
- margin: 1rem;
148
- animation: modal-enter 0.2s ease-out;
152
+ padding: 0;
153
+ margin: auto;
154
+ max-height: calc(100vh - 2rem);
155
+ overflow: auto;
149
156
  }
150
157
 
151
158
  /* Size variants */
152
- .modal-sm { width: 100%; max-width: 20rem; }
153
- .modal-md { width: 100%; max-width: 28rem; }
154
- .modal-lg { width: 100%; max-width: 36rem; }
155
- .modal-xl { width: 100%; max-width: 48rem; }
156
- .modal-full { width: 100%; max-width: calc(100vw - 2rem); max-height: calc(100vh - 2rem); }
159
+ dialog.modal-sm { width: 100%; max-width: 20rem; }
160
+ dialog.modal-md { width: 100%; max-width: 28rem; }
161
+ dialog.modal-lg { width: 100%; max-width: 36rem; }
162
+ dialog.modal-xl { width: 100%; max-width: 48rem; }
163
+ dialog.modal-full { width: calc(100vw - 2rem); max-width: calc(100vw - 2rem); }
164
+
165
+ /* Native backdrop - automatically handled by browser */
166
+ dialog.modal::backdrop {
167
+ background: color-mix(in srgb, var(--color-base00) 80%, transparent);
168
+ backdrop-filter: blur(4px);
169
+ }
170
+
171
+ /* Entry/exit animations using @starting-style */
172
+ dialog.modal[open] {
173
+ opacity: 1;
174
+ transform: scale(1) translateY(0);
175
+ }
176
+
177
+ @starting-style {
178
+ dialog.modal[open] {
179
+ opacity: 0;
180
+ transform: scale(0.95) translateY(10px);
181
+ }
182
+ }
183
+
184
+ dialog.modal[open]::backdrop {
185
+ opacity: 1;
186
+ }
187
+
188
+ @starting-style {
189
+ dialog.modal[open]::backdrop {
190
+ opacity: 0;
191
+ }
192
+ }
193
+
194
+ /* Transitions for smooth open/close */
195
+ dialog.modal {
196
+ transition:
197
+ opacity 0.2s ease-out,
198
+ transform 0.2s ease-out,
199
+ overlay 0.2s ease-out allow-discrete,
200
+ display 0.2s ease-out allow-discrete;
201
+ }
202
+
203
+ dialog.modal::backdrop {
204
+ transition:
205
+ opacity 0.2s ease-out,
206
+ overlay 0.2s ease-out allow-discrete,
207
+ display 0.2s ease-out allow-discrete;
208
+ }
157
209
 
158
210
  .modal-close {
159
211
  position: absolute;
@@ -166,6 +218,7 @@
166
218
  color: var(--color-base05);
167
219
  cursor: pointer;
168
220
  transition: background 0.15s, color 0.15s;
221
+ z-index: 1;
169
222
  }
170
223
 
171
224
  .modal-close:hover {
@@ -218,15 +271,4 @@
218
271
  margin-top: 1.5rem;
219
272
  justify-content: flex-end;
220
273
  }
221
-
222
- @keyframes modal-enter {
223
- from {
224
- opacity: 0;
225
- transform: scale(0.95) translateY(10px);
226
- }
227
- to {
228
- opacity: 1;
229
- transform: scale(1) translateY(0);
230
- }
231
- }
232
274
  </style>
@@ -1,7 +1,8 @@
1
1
  <!--
2
2
  @component Popover
3
3
 
4
- A tooltip/popover component with smart positioning and hover interaction.
4
+ A tooltip/popover component with CSS Anchor Positioning (with JS fallback).
5
+ Uses native popover API for top-layer rendering.
5
6
 
6
7
  @example
7
8
  <Popover content="This is helpful information" position="top">
@@ -28,14 +29,22 @@
28
29
  class: className = ''
29
30
  } = $props();
30
31
 
32
+ // Feature detection for CSS Anchor Positioning
33
+ const supportsAnchor = typeof CSS !== 'undefined' && CSS.supports('anchor-name', '--test');
34
+
31
35
  let visible = $state(false);
32
36
  let timeoutId = $state(null);
33
37
  let popoverEl = $state(null);
34
38
  let triggerEl = $state(null);
35
39
  let isHoveringPopover = $state(false);
40
+
41
+ // Only needed for JS fallback
36
42
  let windowWidth = $state(0);
37
43
  let windowHeight = $state(0);
38
44
 
45
+ // Generate unique anchor name for this instance
46
+ const anchorName = `--popover-anchor-${Math.random().toString(36).slice(2, 9)}`;
47
+
39
48
  // Animation configs based on position
40
49
  const animations = {
41
50
  top: { in: { y: 8, duration: 200 }, out: { y: -8, duration: 150 } },
@@ -51,11 +60,14 @@
51
60
  triggerEl = event.currentTarget;
52
61
  timeoutId = setTimeout(() => {
53
62
  visible = true;
54
- requestAnimationFrame(() => {
63
+ // For JS fallback, position after render
64
+ if (!supportsAnchor) {
55
65
  requestAnimationFrame(() => {
56
- positionPopover();
66
+ requestAnimationFrame(() => {
67
+ positionPopover();
68
+ });
57
69
  });
58
- });
70
+ }
59
71
  }, delay.show);
60
72
  }
61
73
 
@@ -78,8 +90,9 @@
78
90
  handleMouseLeave();
79
91
  }
80
92
 
93
+ // JS fallback positioning (only used when CSS Anchor not supported)
81
94
  function positionPopover() {
82
- if (!popoverEl || !triggerEl) return;
95
+ if (!popoverEl || !triggerEl || supportsAnchor) return;
83
96
 
84
97
  const triggerRect = triggerEl.getBoundingClientRect();
85
98
  const popoverWidth = popoverEl.offsetWidth;
@@ -138,21 +151,25 @@
138
151
  }
139
152
 
140
153
  function handleScroll() {
141
- if (visible && triggerEl && popoverEl) {
154
+ if (visible && triggerEl && popoverEl && !supportsAnchor) {
142
155
  requestAnimationFrame(positionPopover);
143
156
  }
144
157
  }
145
158
  </script>
146
159
 
147
- <svelte:window
148
- bind:innerWidth={windowWidth}
149
- bind:innerHeight={windowHeight}
150
- onscroll={visible ? handleScroll : undefined}
151
- onresize={visible ? handleScroll : undefined}
152
- />
160
+ <!-- Only bind window for JS fallback -->
161
+ {#if !supportsAnchor}
162
+ <svelte:window
163
+ bind:innerWidth={windowWidth}
164
+ bind:innerHeight={windowHeight}
165
+ onscroll={visible ? handleScroll : undefined}
166
+ onresize={visible ? handleScroll : undefined}
167
+ />
168
+ {/if}
153
169
 
154
170
  <div
155
171
  class="popover-wrapper {className}"
172
+ style={supportsAnchor ? `anchor-name: ${anchorName};` : ''}
156
173
  onmouseenter={handleMouseEnter}
157
174
  onmouseleave={handleMouseLeave}
158
175
  >
@@ -161,6 +178,10 @@
161
178
  {#if visible}
162
179
  <div
163
180
  class="popover"
181
+ class:popover-anchor={supportsAnchor}
182
+ class:popover-js={!supportsAnchor}
183
+ data-position={position}
184
+ style={supportsAnchor ? `position-anchor: ${anchorName}; --offset: ${offset}px;` : ''}
164
185
  role="tooltip"
165
186
  in:fly={anim.in}
166
187
  out:fly={anim.out}
@@ -186,8 +207,6 @@
186
207
  }
187
208
 
188
209
  .popover {
189
- position: fixed;
190
- z-index: 9999;
191
210
  min-width: 8rem;
192
211
  max-width: 18rem;
193
212
  width: max-content;
@@ -203,4 +222,44 @@
203
222
  word-wrap: break-word;
204
223
  hyphens: auto;
205
224
  }
225
+
226
+ /* CSS Anchor Positioning (Chrome 125+) */
227
+ .popover-anchor {
228
+ position: absolute;
229
+ inset: unset;
230
+
231
+ /* Position based on data-position attribute */
232
+ &[data-position='top'] {
233
+ bottom: calc(anchor(top) + var(--offset));
234
+ left: anchor(center);
235
+ translate: -50% 0;
236
+ }
237
+
238
+ &[data-position='bottom'] {
239
+ top: calc(anchor(bottom) + var(--offset));
240
+ left: anchor(center);
241
+ translate: -50% 0;
242
+ }
243
+
244
+ &[data-position='left'] {
245
+ right: calc(anchor(left) + var(--offset));
246
+ top: anchor(center);
247
+ translate: 0 -50%;
248
+ }
249
+
250
+ &[data-position='right'] {
251
+ left: calc(anchor(right) + var(--offset));
252
+ top: anchor(center);
253
+ translate: 0 -50%;
254
+ }
255
+
256
+ /* Auto-flip when near viewport edges */
257
+ position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline;
258
+ }
259
+
260
+ /* JS Fallback positioning */
261
+ .popover-js {
262
+ position: fixed;
263
+ z-index: 9999;
264
+ }
206
265
  </style>