Skip to content

Commit 98a048e

Browse files
feat: add <StxModal>, stxAlert(), and stxConfirm() builtins
Three new built-in UI primitives: 1. <StxModal id="name"> — declarative modal with slot content. Opened/closed via modal.open(id), modal.close(id), modal.toggle(id). Supports size (sm/md/lg/xl/full), closeOnBackdrop, closeOnEscape. Animated open/close with backdrop blur and scale transition. Locks body scroll while open, restores on close. 2. stxAlert(message, options?) — styled window.alert() replacement. Returns Promise<void>. Supports title, type (info/warning/error/ success), custom button text. Renders as a centered dialog with icon, backdrop, and animated entry/exit. 3. stxConfirm(message, options?) — styled window.confirm() replacement. Returns Promise<boolean>. Cancel + OK buttons, Escape = cancel. Type 'question' shows a ? icon by default. Supports custom confirmText/cancelText. All three: - Dark mode via prefers-color-scheme - Keyboard accessible (Escape to close/cancel, autofocus primary button) - ARIA attributes (role="dialog", aria-modal) - Zero dependencies, just DOM manipulation - Available as runtime globals in <script client> blocks Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 51adeef commit 98a048e

4 files changed

Lines changed: 274 additions & 0 deletions

File tree

packages/stx/src/builtins/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,15 @@ export { StxLinkBuiltin } from './stx-link'
1111
export { StxImageBuiltin } from './stx-image'
1212
export { StxLoadingIndicatorBuiltin } from './stx-loading-indicator'
1313
export { StxToastBuiltin } from './toast'
14+
export { StxModalBuiltin } from './modal'
1415
export { IconBuiltin, preloadIconCollection } from './icon'
1516

1617
import { registry } from '../component-registry'
1718
import { StxLinkBuiltin } from './stx-link'
1819
import { StxImageBuiltin } from './stx-image'
1920
import { StxLoadingIndicatorBuiltin } from './stx-loading-indicator'
2021
import { StxToastBuiltin } from './toast'
22+
import { StxModalBuiltin } from './modal'
2123
import { IconBuiltin } from './icon'
2224

2325
/**
@@ -29,5 +31,6 @@ export function registerBuiltins(): void {
2931
registry.registerBuiltin(StxImageBuiltin)
3032
registry.registerBuiltin(StxLoadingIndicatorBuiltin)
3133
registry.registerBuiltin(StxToastBuiltin)
34+
registry.registerBuiltin(StxModalBuiltin)
3235
registry.registerBuiltin(IconBuiltin)
3336
}

packages/stx/src/builtins/modal.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/**
2+
* StxModal Builtin Component
3+
*
4+
* Renders a hidden modal dialog with slot content. Opened/closed via
5+
* the runtime `modal.open(id)` / `modal.close(id)` globals.
6+
*
7+
* Usage:
8+
* <StxModal id="settings">
9+
* <h2>Settings</h2>
10+
* <p>Modal content here</p>
11+
* </StxModal>
12+
*
13+
* <StxModal id="delete-confirm" size="sm" closeOnBackdrop="false">
14+
* <h3>Are you sure?</h3>
15+
* </StxModal>
16+
*
17+
* Props:
18+
* - id (required) — unique modal identifier
19+
* - size — 'sm' | 'md' | 'lg' | 'xl' | 'full' (default: 'md')
20+
* - closeOnBackdrop — whether clicking backdrop closes modal (default: true)
21+
* - closeOnEscape — whether Escape key closes modal (default: true)
22+
*
23+
* @module builtins/modal
24+
*/
25+
26+
import type { BuiltinComponentDef, ResolvedProps, RenderContext } from '../component-registry'
27+
28+
function resolveProp(props: ResolvedProps, key: string): string | undefined {
29+
if (props.serverDynamic[key] !== undefined) return String(props.serverDynamic[key])
30+
const val = props.static[key]
31+
if (typeof val === 'string') return val
32+
return undefined
33+
}
34+
35+
const SIZE_WIDTHS: Record<string, string> = {
36+
sm: 'max-width:24rem',
37+
md: 'max-width:32rem',
38+
lg: 'max-width:42rem',
39+
xl: 'max-width:56rem',
40+
full: 'max-width:calc(100vw - 2rem);max-height:calc(100vh - 2rem)',
41+
}
42+
43+
export const StxModalBuiltin: BuiltinComponentDef = {
44+
name: 'StxModal',
45+
aliases: ['stx-modal'],
46+
47+
render(props: ResolvedProps, slotContent: string, _ctx: RenderContext): string {
48+
const id = resolveProp(props, 'id')
49+
if (!id) {
50+
console.warn('[stx] <StxModal> requires an "id" prop')
51+
return '<!-- StxModal: missing id prop -->'
52+
}
53+
54+
const size = resolveProp(props, 'size') || 'md'
55+
const closeOnBackdrop = resolveProp(props, 'closeOnBackdrop') !== 'false'
56+
const closeOnEscape = resolveProp(props, 'closeOnEscape') !== 'false'
57+
const maxWidth = SIZE_WIDTHS[size] || SIZE_WIDTHS.md
58+
59+
return `<div id="stx-modal-${id}" class="stx-modal-backdrop" data-stx-modal="${id}" data-close-backdrop="${closeOnBackdrop}" data-close-escape="${closeOnEscape}" style="display:none;position:fixed;inset:0;z-index:99999;align-items:center;justify-content:center;background:rgba(0,0,0,0.5);backdrop-filter:blur(2px);opacity:0;transition:opacity 0.2s ease" aria-modal="true" role="dialog">
60+
<div class="stx-modal-panel" style="${maxWidth};width:100%;margin:1rem;border-radius:0.75rem;box-shadow:0 20px 60px rgba(0,0,0,0.3);overflow:hidden;transform:scale(0.95) translateY(8px);transition:transform 0.2s ease,opacity 0.2s ease;opacity:0;background:var(--stx-modal-bg,#fff);color:var(--stx-modal-text,#1f2937)">
61+
<div style="padding:1.5rem">${slotContent}</div>
62+
</div>
63+
</div>
64+
<style>
65+
@media(prefers-color-scheme:dark){[data-stx-modal]{--stx-modal-bg:#1f2937;--stx-modal-text:#f3f4f6}}
66+
.stx-modal-backdrop[data-stx-modal-open]{display:flex!important;opacity:1}
67+
.stx-modal-backdrop[data-stx-modal-open] .stx-modal-panel{transform:scale(1) translateY(0);opacity:1}
68+
</style>`
69+
},
70+
}

packages/stx/src/signals.ts

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3005,6 +3005,158 @@ catch (e) {}
30053005
}
30063006
};
30073007
3008+
// ── Modal system ──────────────────────────────────────────────────
3009+
var modal = {
3010+
open: function(id) {
3011+
var el = document.querySelector('[data-stx-modal="' + id + '"]');
3012+
if (!el) { console.warn('[stx:modal] Modal "' + id + '" not found'); return; }
3013+
el.style.display = 'flex';
3014+
// Force reflow then animate
3015+
void el.offsetHeight;
3016+
el.setAttribute('data-stx-modal-open', '');
3017+
document.body.style.overflow = 'hidden';
3018+
// Escape key handler
3019+
if (el.getAttribute('data-close-escape') !== 'false') {
3020+
var escHandler = function(e) {
3021+
if (e.key === 'Escape') { modal.close(id); document.removeEventListener('keydown', escHandler); }
3022+
};
3023+
document.addEventListener('keydown', escHandler);
3024+
el._stxEscHandler = escHandler;
3025+
}
3026+
// Backdrop click
3027+
if (el.getAttribute('data-close-backdrop') !== 'false') {
3028+
el.onclick = function(e) { if (e.target === el) modal.close(id); };
3029+
}
3030+
},
3031+
close: function(id) {
3032+
var el = document.querySelector('[data-stx-modal="' + id + '"]');
3033+
if (!el) return;
3034+
el.removeAttribute('data-stx-modal-open');
3035+
if (el._stxEscHandler) { document.removeEventListener('keydown', el._stxEscHandler); el._stxEscHandler = null; }
3036+
el.onclick = null;
3037+
setTimeout(function() {
3038+
el.style.display = 'none';
3039+
// Restore scroll if no other modals are open
3040+
if (!document.querySelector('[data-stx-modal-open]')) document.body.style.overflow = '';
3041+
}, 200);
3042+
},
3043+
toggle: function(id) {
3044+
var el = document.querySelector('[data-stx-modal="' + id + '"]');
3045+
if (el && el.hasAttribute('data-stx-modal-open')) modal.close(id);
3046+
else modal.open(id);
3047+
}
3048+
};
3049+
3050+
// ── Alert & Confirm dialogs ─────────────────────────────────────
3051+
// Styled replacements for window.alert() and window.confirm().
3052+
// Both return Promises and render into a temporary modal overlay.
3053+
3054+
var _dialogId = 0;
3055+
var _dialogIcons = {
3056+
info: '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#2563eb" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>',
3057+
warning: '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#d97706" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>',
3058+
error: '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#dc2626" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>',
3059+
success: '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#16a34a" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M9 12l2 2 4-4"/></svg>',
3060+
question: '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#6366f1" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 015.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>'
3061+
};
3062+
3063+
function _createDialog(message, options, isConfirm) {
3064+
var opts = options || {};
3065+
var type = opts.type || (isConfirm ? 'question' : 'info');
3066+
var title = opts.title || '';
3067+
var confirmText = opts.confirmText || 'OK';
3068+
var cancelText = opts.cancelText || 'Cancel';
3069+
var id = 'stx-dialog-' + (++_dialogId);
3070+
var isDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
3071+
var bg = isDark ? '#1f2937' : '#ffffff';
3072+
var textColor = isDark ? '#f3f4f6' : '#1f2937';
3073+
var subColor = isDark ? '#9ca3af' : '#6b7280';
3074+
var icon = _dialogIcons[type] || _dialogIcons.info;
3075+
3076+
return new Promise(function(resolve) {
3077+
var backdrop = document.createElement('div');
3078+
backdrop.id = id;
3079+
backdrop.style.cssText = 'position:fixed;inset:0;z-index:999999;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.5);backdrop-filter:blur(2px);opacity:0;transition:opacity 0.2s ease;font-family:system-ui,-apple-system,sans-serif';
3080+
backdrop.setAttribute('role', 'alertdialog');
3081+
backdrop.setAttribute('aria-modal', 'true');
3082+
3083+
var panel = document.createElement('div');
3084+
panel.style.cssText = 'max-width:24rem;width:calc(100% - 2rem);border-radius:0.75rem;padding:1.5rem;background:' + bg + ';color:' + textColor + ';box-shadow:0 20px 60px rgba(0,0,0,0.3);transform:scale(0.95);transition:transform 0.2s ease;text-align:center';
3085+
3086+
var iconDiv = document.createElement('div');
3087+
iconDiv.style.cssText = 'display:flex;justify-content:center;margin-bottom:1rem';
3088+
iconDiv.innerHTML = icon;
3089+
panel.appendChild(iconDiv);
3090+
3091+
if (title) {
3092+
var titleEl = document.createElement('h3');
3093+
titleEl.style.cssText = 'margin:0 0 0.5rem;font-size:1.125rem;font-weight:600';
3094+
titleEl.textContent = title;
3095+
panel.appendChild(titleEl);
3096+
}
3097+
3098+
var msgEl = document.createElement('p');
3099+
msgEl.style.cssText = 'margin:0 0 1.25rem;font-size:0.875rem;line-height:1.5;color:' + subColor;
3100+
msgEl.textContent = message;
3101+
panel.appendChild(msgEl);
3102+
3103+
var btnRow = document.createElement('div');
3104+
btnRow.style.cssText = 'display:flex;gap:0.75rem;justify-content:center';
3105+
3106+
function cleanup(result) {
3107+
backdrop.style.opacity = '0';
3108+
panel.style.transform = 'scale(0.95)';
3109+
setTimeout(function() { backdrop.remove(); }, 200);
3110+
resolve(result);
3111+
}
3112+
3113+
if (isConfirm) {
3114+
var cancelBtn = document.createElement('button');
3115+
cancelBtn.textContent = cancelText;
3116+
cancelBtn.style.cssText = 'padding:0.5rem 1.25rem;border-radius:0.5rem;font-size:0.875rem;font-weight:500;cursor:pointer;border:1px solid ' + (isDark ? '#374151' : '#d1d5db') + ';background:transparent;color:' + textColor + ';transition:background 0.15s';
3117+
cancelBtn.onmouseover = function() { this.style.background = isDark ? '#374151' : '#f3f4f6'; };
3118+
cancelBtn.onmouseout = function() { this.style.background = 'transparent'; };
3119+
cancelBtn.onclick = function() { cleanup(false); };
3120+
btnRow.appendChild(cancelBtn);
3121+
}
3122+
3123+
var okBtn = document.createElement('button');
3124+
okBtn.textContent = confirmText;
3125+
var btnColor = type === 'error' ? '#dc2626' : type === 'warning' ? '#d97706' : '#5672cd';
3126+
okBtn.style.cssText = 'padding:0.5rem 1.25rem;border-radius:0.5rem;font-size:0.875rem;font-weight:500;cursor:pointer;border:none;background:' + btnColor + ';color:#fff;transition:opacity 0.15s';
3127+
okBtn.onmouseover = function() { this.style.opacity = '0.9'; };
3128+
okBtn.onmouseout = function() { this.style.opacity = '1'; };
3129+
okBtn.onclick = function() { cleanup(isConfirm ? true : undefined); };
3130+
btnRow.appendChild(okBtn);
3131+
3132+
panel.appendChild(btnRow);
3133+
backdrop.appendChild(panel);
3134+
document.body.appendChild(backdrop);
3135+
3136+
// Animate in
3137+
void backdrop.offsetHeight;
3138+
backdrop.style.opacity = '1';
3139+
panel.style.transform = 'scale(1)';
3140+
3141+
// Escape key
3142+
var escHandler = function(e) {
3143+
if (e.key === 'Escape') { cleanup(isConfirm ? false : undefined); document.removeEventListener('keydown', escHandler); }
3144+
};
3145+
document.addEventListener('keydown', escHandler);
3146+
3147+
// Focus the primary button
3148+
okBtn.focus();
3149+
});
3150+
}
3151+
3152+
function stxAlert(message, options) {
3153+
return _createDialog(message, options, false);
3154+
}
3155+
3156+
function stxConfirm(message, options) {
3157+
return _createDialog(message, options, true);
3158+
}
3159+
30083160
// Component mount system
30093161
var mountQueue = [];
30103162
@@ -3054,6 +3206,9 @@ catch (e) {}
30543206
useHead,
30553207
useSeoMeta,
30563208
toast,
3209+
modal,
3210+
alert: stxAlert,
3211+
confirm: stxConfirm,
30573212
helpers: globalHelpers,
30583213
30593214
// Component composition API (Phase 4)
@@ -3510,6 +3665,9 @@ else {
35103665
window.useHead = useHead;
35113666
window.useSeoMeta = useSeoMeta;
35123667
window.toast = toast;
3668+
window.modal = modal;
3669+
window.stxAlert = stxAlert;
3670+
window.stxConfirm = stxConfirm;
35133671
window.defineStore = window.stx.defineStore;
35143672
window.useStore = window.stx.useStore;
35153673
window.ref = state;

packages/stx/stx.d.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,42 @@ interface StxToast {
325325

326326
declare const toast: StxToast
327327

328+
// ============================================================================
329+
// Modal system
330+
// ============================================================================
331+
332+
interface StxModal {
333+
/** Open a modal by its id */
334+
open: (id: string) => void
335+
/** Close a modal by its id */
336+
close: (id: string) => void
337+
/** Toggle a modal by its id */
338+
toggle: (id: string) => void
339+
}
340+
341+
declare const modal: StxModal
342+
343+
// ============================================================================
344+
// Alert & Confirm dialogs
345+
// ============================================================================
346+
347+
interface StxDialogOptions {
348+
/** Dialog title displayed above the message */
349+
title?: string
350+
/** Icon type: 'info' | 'warning' | 'error' | 'success' | 'question' */
351+
type?: 'info' | 'warning' | 'error' | 'success' | 'question'
352+
/** Text for the confirm/OK button (default: 'OK') */
353+
confirmText?: string
354+
/** Text for the cancel button — confirm only (default: 'Cancel') */
355+
cancelText?: string
356+
}
357+
358+
/** Styled replacement for window.alert(). Returns a Promise that resolves when dismissed. */
359+
declare function stxAlert(message: string, options?: StxDialogOptions): Promise<void>
360+
361+
/** Styled replacement for window.confirm(). Returns a Promise<boolean>. */
362+
declare function stxConfirm(message: string, options?: StxDialogOptions): Promise<boolean>
363+
328364
// ============================================================================
329365
// Stores (Pinia-inspired, signals-based)
330366
// ============================================================================
@@ -439,6 +475,13 @@ interface StxRuntimeRegistry {
439475
// Toast
440476
toast: StxToast
441477

478+
// Modal
479+
modal: StxModal
480+
481+
// Dialogs
482+
alert: typeof stxAlert
483+
confirm: typeof stxConfirm
484+
442485
// Mount API
443486
mount: (setupFn: () => any) => void
444487
mountEl: (selector: string, setupFn: () => any) => void

0 commit comments

Comments
 (0)