component Modal { outputs { open(payload: { sourceEvent: Event }) close(payload: { reason: string; sourceEvent: Event }) cancel(payload: { sourceEvent: Event }) confirm(payload: { sourceEvent: Event }) } props { open: boolean = false defaultOpen: boolean = false title: string = "Modal" description: string = "" icon: string = "" label: string = "Modal dialog" size: string = "md" placement: string = "center" color: string = "primary" variant: string = "default" showClose: boolean = true closeLabel: string = "Close modal" closeOnBackdrop: boolean = true closeOnEscape: boolean = true closeOnCancel: boolean = true closeOnConfirm: boolean = false showFooter: boolean = true cancelLabel: string = "Cancel" cancelIcon: string = "" confirmLabel: string = "Confirm" confirmIcon: string = "" confirmDisabled: boolean = false confirmLoading: boolean = false destructive: boolean = false triggerLabel: string = "" triggerIcon: string = "" scrollable: boolean = true scrollBehavior: string = "inside" class: string = "" } state visible = defaultOpen functions { shared function isOpen() { return open || visible } client function showModal(sourceEvent) { visible = true output.open({ sourceEvent: sourceEvent }) } // Slot content can close the modal it sits in by dispatching a bubbling // wrnexus:modal:close event, e.g. from a form success handler: // // event.target.dispatchEvent( // new CustomEvent("wrnexus:modal:close", { bubbles: true }) // ) // // The listener is on the modal root, so the event only ever closes the // modal the dispatching element is actually inside -- no ids to wrn up // and no way to close somebody else's modal by accident. client function hideModal(reason, sourceEvent) { visible = false output.close({ reason: reason, sourceEvent: sourceEvent }) } client function cancelModal(sourceEvent) { output.cancel({ sourceEvent: sourceEvent }) if (closeOnCancel) { hideModal("cancel", sourceEvent) } } client function confirmModal(sourceEvent) { if (confirmDisabled || confirmLoading) { return } output.confirm({ sourceEvent: sourceEvent }) if (closeOnConfirm) { hideModal("confirm", sourceEvent) } } client function handleKeydown(sourceEvent) { if (closeOnEscape && sourceEvent.key === "Escape") { sourceEvent.preventDefault() cancelModal(sourceEvent) } } } view {
{#if triggerLabel} {/if}
} style { .wrn-modal { --modal-accent: var(--wrn-color-primary); --modal-soft: var(--wrn-color-primary-soft); --modal-contrast: var(--wrn-color-primary-contrast); position: relative; display: inline-flex; } .wrn-modal[data-color="secondary"] { --modal-accent: var(--wrn-color-secondary); --modal-soft: var(--wrn-color-secondary-soft); --modal-contrast: var(--wrn-color-secondary-contrast); } /* * Fallbacks below (the second var() argument): the theme token generator * (packages/styles/src/theme.ts) only emits a real -contrast token for * primary and secondary. info, success, and danger have no contrast * token defined at all, so var(--wrn-color-info-contrast) with no * fallback resolves to nothing, and --modal-contrast becomes invalid -- * which made confirm-button and solid-panel text unreadable. White is a * safe default against these saturated colors until the theme package * defines real tokens for them. */ .wrn-modal[data-color="info"] { --modal-accent: var(--wrn-color-info); --modal-soft: var(--wrn-color-info-soft); --modal-contrast: var(--wrn-color-info-contrast, white); } .wrn-modal[data-color="success"] { --modal-accent: var(--wrn-color-success); --modal-soft: var(--wrn-color-success-soft); --modal-contrast: var(--wrn-color-success-contrast, white); } .wrn-modal[data-color="warning"] { --modal-accent: var(--wrn-color-warning); --modal-soft: var(--wrn-color-warning-soft); --modal-contrast: var(--wrn-color-warning-text); } .wrn-modal[data-color="danger"], .wrn-modal[data-destructive="true"] { --modal-accent: var(--wrn-color-danger); --modal-soft: var(--wrn-color-danger-soft); --modal-contrast: var(--wrn-color-on-danger, white); } .wrn-modal__trigger, .wrn-modal__trigger-slot { display: inline-flex; align-items: center; gap: 0.55rem; } .wrn-modal__trigger { appearance: none; min-height: 2.55rem; padding: 0.65rem 1rem; color: var(--modal-contrast); background: var(--modal-accent); border: 0; border-radius: 0.8rem; font: inherit; font-size: 0.85rem; font-weight: 650; cursor: pointer; transition: opacity 150ms ease, transform 150ms ease, box-shadow 150ms ease; } .wrn-modal__trigger:hover { opacity: 0.92; } .wrn-modal__trigger:active { transform: scale(0.97); } .wrn-modal__trigger:focus-visible { outline: none; box-shadow: 0 0 0 3px color-mix(in srgb, var(--modal-accent) 40%, transparent); } /* * Hidden by default so the SSR-rendered HTML never paints the layer before * hydration runs. data-open on the wrn-modal root is evaluated and * serialized to a real true or false string at render time -- a plain * bind, not the raw-expression data-show directive -- so this selector * is correct on first paint with zero flash, with no dependency on * client JS having run yet. The data-show attribute and client directive * still run after hydration to keep things in sync for state changes, * but visibility itself is driven by CSS here. visibility (rather than * display) is used so the open and close transitions below can actually * animate -- a box that starts at display: none has no prior frame to * transition from. */ .wrn-modal__layer { position: fixed; inset: 0; z-index: 1250; display: flex; align-items: center; justify-content: center; padding: 1rem; visibility: hidden; opacity: 0; transition: opacity 180ms ease, visibility 0s linear 180ms; } .wrn-modal[data-open="true"] .wrn-modal__layer { visibility: visible; opacity: 1; transition: opacity 180ms ease, visibility 0s linear 0s; } .wrn-modal[data-placement="top"] .wrn-modal__layer { align-items: flex-start; padding-top: clamp(1rem, 8vh, 5rem); } .wrn-modal[data-scroll="page"] .wrn-modal__layer { align-items: flex-start; overflow-y: auto; padding: 2.5rem 1rem; } .wrn-modal__backdrop { position: absolute; inset: 0; z-index: 0; appearance: none; padding: 0; background: color-mix(in srgb, black 62%, transparent); border: 0; backdrop-filter: blur(9px); } .wrn-modal__panel { position: relative; z-index: 1; display: flex; flex-direction: column; width: min(32rem, calc(100vw - 2rem)); max-height: min(88vh, 52rem); color: var(--wrn-color-text); background: radial-gradient( circle at 100% 0%, color-mix(in srgb, var(--modal-accent) 9%, transparent), transparent 34% ), var(--wrn-color-surface-raised); border: 1px solid color-mix(in srgb, var(--modal-accent) 18%, var(--wrn-color-border)); border-radius: 1.35rem; box-shadow: 0 1px 0 color-mix(in srgb, white 5%, transparent) inset, 0 40px 110px color-mix(in srgb, black 38%, transparent); overflow: hidden; opacity: 0; transform: scale(0.96) translateY(10px); transition: opacity 180ms ease, transform 220ms cubic-bezier(0.16, 1, 0.3, 1); } .wrn-modal[data-open="true"] .wrn-modal__panel { opacity: 1; transform: none; } .wrn-modal[data-size="xs"] .wrn-modal__panel { width: min(19rem, calc(100vw - 2rem)); } .wrn-modal[data-size="sm"] .wrn-modal__panel { width: min(25rem, calc(100vw - 2rem)); } .wrn-modal[data-size="lg"] .wrn-modal__panel { width: min(44rem, calc(100vw - 2rem)); } .wrn-modal[data-size="xl"] .wrn-modal__panel { width: min(64rem, calc(100vw - 2rem)); } .wrn-modal[data-size="full"] .wrn-modal__panel { width: calc(100vw - 2rem); height: calc(100vh - 2rem); max-height: none; } .wrn-modal[data-scroll="page"] .wrn-modal__panel { max-height: none; } .wrn-modal[data-variant="soft"] .wrn-modal__panel { background: linear-gradient(145deg, var(--modal-soft), transparent 68%), var(--wrn-color-surface-raised); } .wrn-modal[data-variant="outline"] .wrn-modal__panel { background: var(--wrn-color-surface-raised); box-shadow: 0 24px 72px color-mix(in srgb, black 24%, transparent); } .wrn-modal[data-variant="solid"] .wrn-modal__panel { color: var(--modal-contrast); background: var(--modal-accent); border-color: color-mix(in srgb, white 20%, transparent); } .wrn-modal__header { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; padding: 1.4rem 1.4rem 1.15rem; border-bottom: 1px solid var(--wrn-color-border); } .wrn-modal__heading { display: flex; align-items: flex-start; gap: 0.9rem; min-width: 0; } .wrn-modal__icon { flex: 0 0 auto; width: 1.3rem; height: 1.3rem; margin-top: 0.15rem; color: var(--modal-accent); } .wrn-modal[data-variant="solid"] .wrn-modal__icon { color: currentColor; } .wrn-modal__heading-copy { display: grid; gap: 0.3rem; min-width: 0; } .wrn-modal__heading-copy h2, .wrn-modal__heading-copy p { margin: 0; } .wrn-modal__heading-copy h2 { color: inherit; font-size: 1.08rem; font-weight: 650; line-height: 1.3; } .wrn-modal__heading-copy p { color: var(--wrn-color-text-muted); font-size: 0.8rem; line-height: 1.55; } .wrn-modal[data-variant="solid"] .wrn-modal__heading-copy p { color: color-mix(in srgb, currentColor 76%, transparent); } /* * padding is reset explicitly: an app-level `button { padding: … }` rule * outranks the browser default, and 1rem of horizontal padding left this * 2.15rem button with a ~2px content box -- which squeezed the icon to * 0.4px wide and read as "the close button has no icon". */ .wrn-modal__close { appearance: none; display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; padding: 0; width: 2.15rem; height: 2.15rem; color: var(--wrn-color-text-muted); background: transparent; border: 1px solid transparent; border-radius: 9999px; cursor: pointer; transition: background 150ms ease, color 150ms ease, border-color 150ms ease, transform 150ms ease; } /* * On a solid panel the background is the accent color, so the muted-grey * default is close to invisible -- the dismiss affordance reads as * missing rather than subtle. Derive it from the panel contrast color * instead, and give it a faint ring so it is unmistakably a control. */ .wrn-modal[data-variant="solid"] .wrn-modal__close { color: color-mix(in srgb, currentColor 82%, transparent); border-color: color-mix(in srgb, currentColor 35%, transparent); } .wrn-modal[data-variant="solid"] .wrn-modal__close:hover { color: currentColor; background: color-mix(in srgb, black 18%, transparent); border-color: color-mix(in srgb, currentColor 55%, transparent); } .wrn-modal__close:hover { color: var(--wrn-color-text); background: var(--wrn-color-surface-soft); } .wrn-modal__close:focus-visible { color: var(--modal-accent); border-color: color-mix(in srgb, var(--modal-accent) 45%, transparent); outline: none; } .wrn-modal__close:active { transform: scale(0.92); } /* Never let the glyph be shrunk by the flex container. */ .wrn-modal__close svg { flex: 0 0 auto; width: 1rem; height: 1rem; } /* * Slot content is authored by the host app, so the app global stylesheet * styles it too. A bare element selector there (p { color: ... }) beats * anything the panel merely *inherits*, which is how solid-variant modals * ended up with muted grey body copy on a saturated accent background -- * unreadable, and worst exactly where contrast matters most (the * destructive confirm). Setting the color on the body makes the panel * choice explicit instead of leaving it to inheritance. * * NOTE: apostrophes are avoided in .wrn style comments on purpose -- the * block scanner treats a quote as a string delimiter while it counts * braces, so a stray one breaks parsing of the whole component. */ .wrn-modal__body { flex: 1 1 auto; min-height: 0; padding: 1.4rem; color: var(--wrn-color-text); } /* * :where() keeps this at the specificity of .wrn-modal__body alone, so it * outranks a global element selector but still yields to any class the app * puts on its own slot content (an error message, a muted caption). A * plain .wrn-modal__body p list would have quietly overridden those. */ .wrn-modal__body :where(p, li, dd, dt, h1, h2, h3, h4, h5, h6, span, label, code) { color: inherit; } .wrn-modal[data-variant="solid"] .wrn-modal__body { color: var(--modal-contrast); } .wrn-modal[data-scrollable="true"] .wrn-modal__body { overflow: auto; overscroll-behavior: contain; } .wrn-modal__footer { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 1rem 1.4rem 1.4rem; border-top: 1px solid var(--wrn-color-border); } .wrn-modal__custom-footer:empty { display: none; } .wrn-modal__actions { display: flex; align-items: center; justify-content: flex-end; gap: 0.65rem; margin-left: auto; } .wrn-modal__button { appearance: none; display: inline-flex; align-items: center; justify-content: center; gap: 0.5rem; min-height: 2.55rem; padding: 0.65rem 1rem; border-radius: 0.78rem; font: inherit; font-size: 0.83rem; font-weight: 650; cursor: pointer; transition: transform 150ms ease, opacity 150ms ease, border-color 150ms ease; } .wrn-modal__button:active { transform: scale(0.985); } .wrn-modal__button--secondary { color: var(--wrn-color-text); background: transparent; border: 1px solid var(--wrn-color-border); } .wrn-modal__button--primary { color: var(--modal-contrast); background: var(--modal-accent); border: 1px solid transparent; } /* * On a solid-variant panel the panel background is also --modal-accent, * so a plain primary button (same color) has no visible edge against it. * Darken the fill slightly and add a light border so the button still * reads as a distinct, clickable pill instead of blending into the panel. */ .wrn-modal[data-variant="solid"] .wrn-modal__button--primary { background: color-mix(in srgb, black 18%, var(--modal-accent)); border-color: color-mix(in srgb, white 32%, transparent); box-shadow: 0 1px 0 color-mix(in srgb, white 12%, transparent) inset; } .wrn-modal__button:disabled { opacity: 0.55; cursor: not-allowed; } .wrn-modal__spinner { width: 0.95rem; height: 0.95rem; border: 2px solid color-mix(in srgb, currentColor 35%, transparent); border-top-color: currentColor; border-radius: 50%; animation: wrn-modal-spin 750ms linear infinite; } @keyframes wrn-modal-spin { to { transform: rotate(360deg); } } @media (max-width: 639px) { .wrn-modal__layer { align-items: flex-end; padding: 0; } .wrn-modal__panel, .wrn-modal[data-size="xs"] .wrn-modal__panel, .wrn-modal[data-size="sm"] .wrn-modal__panel, .wrn-modal[data-size="lg"] .wrn-modal__panel, .wrn-modal[data-size="xl"] .wrn-modal__panel { width: 100%; max-height: 92vh; border-radius: 1.25rem 1.25rem 0 0; } .wrn-modal__footer { align-items: stretch; flex-direction: column; } .wrn-modal__actions { width: 100%; } .wrn-modal__button { flex: 1 1 0; } } @media (prefers-reduced-motion: reduce) { .wrn-modal__button, .wrn-modal__spinner, .wrn-modal__layer, .wrn-modal__panel, .wrn-modal__close { animation: none; transition: none; } .wrn-modal__panel { transform: none; } } } }