436 lines
14 KiB
Plaintext
436 lines
14 KiB
Plaintext
import Input from "@wrnexus/ui/components/input.wrn"
|
|
import Modal from "@wrnexus/ui/components/Modal.wrn"
|
|
import Toaster from "@wrnexus/ui/components/Toaster.wrn"
|
|
|
|
// Modal demo page. Route: /modal
|
|
//
|
|
// Five real-world use cases for the shared @wrnexus/ui Modal component — each
|
|
// one exercises a different combination of props (color, variant, size,
|
|
// scrollable, destructive, showFooter, confirmDisabled) instead of just
|
|
// showing the same modal five times.
|
|
//
|
|
// Notifications go through the shared <Toaster /> mounted at the bottom of
|
|
// this page. Any client expression can raise one with the runtime global —
|
|
// toast("..."), toast.success(...), toast.error(...) — so pages never
|
|
// hand-roll a status banner and never reach for a blocking alert().
|
|
page ModalPage {
|
|
|
|
state noteText = "Remember to ship the release notes"
|
|
state deletedNote = ""
|
|
|
|
functions {
|
|
function onSaveConfirmed() {
|
|
toast.success("Changes saved", { title: "Draft published" })
|
|
}
|
|
|
|
function onDeleteConfirmed() {
|
|
toast.error("Account deleted", { title: "Gone for good", duration: 6000 })
|
|
}
|
|
|
|
// Toast actions and component state.
|
|
//
|
|
// An action callback runs LONG AFTER the function that created it
|
|
// returned, and a client function only flushes its state when its body
|
|
// ends -- so assigning noteText straight from the callback would write to
|
|
// a dead local and vanish. The callback dispatches an event instead, and
|
|
// the declarative @window binding on the actions row below handles it as
|
|
// a fresh invocation with live state. Anything that is not component
|
|
// state (calling toast(), fetch, navigation) works from the callback
|
|
// directly.
|
|
function onDeleteNote() {
|
|
deletedNote = noteText
|
|
noteText = ""
|
|
toast("Note deleted", {
|
|
title: "Deleted",
|
|
actionLabel: "Undo",
|
|
onAction: function () {
|
|
window.dispatchEvent(new CustomEvent("demo:restore-note"))
|
|
},
|
|
duration: 8000
|
|
})
|
|
}
|
|
|
|
function restoreNote() {
|
|
if (!deletedNote) {
|
|
return
|
|
}
|
|
noteText = deletedNote
|
|
deletedNote = ""
|
|
toast.success("Note restored")
|
|
}
|
|
|
|
// Two actions. The second is styled destructive and keeps the toast open
|
|
// (dismiss: false) so the choice stays on screen until it is resolved.
|
|
function onConflict() {
|
|
toast.warning("This note changed in another tab", {
|
|
title: "Conflict",
|
|
actions: [
|
|
{
|
|
label: "Keep mine",
|
|
onClick: function () { toast.success("Kept your version") }
|
|
},
|
|
{
|
|
label: "Discard",
|
|
tone: "danger",
|
|
dismiss: false,
|
|
onClick: function () {
|
|
window.dispatchEvent(new CustomEvent("demo:discard-note"))
|
|
}
|
|
}
|
|
],
|
|
duration: 0
|
|
})
|
|
}
|
|
|
|
function discardNote() {
|
|
deletedNote = noteText
|
|
noteText = ""
|
|
toast.error("Your changes were discarded")
|
|
}
|
|
|
|
// Tones carry their own icon and colour, but any toast can override it.
|
|
// The class lives in this page source, so the app Tailwind/iconify build
|
|
// sees it and emits the rule -- a class from inside @wrnexus/ui would not
|
|
// be scanned, which is why the packaged defaults are inline SVG.
|
|
function onTrialNotice() {
|
|
toast.warning("Your trial ends in 3 days", {
|
|
title: "Heads up",
|
|
icon: "icon-[lucide--hourglass]",
|
|
actionLabel: "Upgrade",
|
|
duration: 6000
|
|
})
|
|
}
|
|
|
|
function onTermsConfirmed() {
|
|
toast.success("Thanks, you are all set")
|
|
}
|
|
|
|
// The invite form is a real <form data-schema="invite">, so
|
|
// @wrnexus/validation owns the rules: it validates on input/blur/submit
|
|
// against app/schemas/invite.ts, writes each message into the
|
|
// [data-error] slot the Input renders, and only then POSTs to /api/invite — which parses
|
|
// the very same schema server-side. These two handlers just react to the
|
|
// outcome events the validator emits.
|
|
function onInviteSent(event) {
|
|
toast.success("Invite sent to " + event.detail.email, { title: "Invitation" })
|
|
// Close the modal the form lives in. The event bubbles from the form up
|
|
// to the Modal root, which listens for it -- so the page never needs a
|
|
// handle on the modal or a way to reach its internal state.
|
|
event.target.dispatchEvent(
|
|
new CustomEvent("wrnexus:modal:close", { bubbles: true })
|
|
)
|
|
}
|
|
|
|
function onInviteFailed(event) {
|
|
toast.error(event.detail.message || "Could not send the invite", {
|
|
title: "Invite failed"
|
|
})
|
|
}
|
|
}
|
|
|
|
style {
|
|
.modal-demo-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
|
gap: 1rem;
|
|
margin-top: 1.5rem;
|
|
}
|
|
|
|
.modal-demo-card {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.6rem;
|
|
padding: 1.1rem;
|
|
background: var(--wrn-color-surface-raised);
|
|
border: 1px solid var(--wrn-color-border);
|
|
border-radius: 1rem;
|
|
}
|
|
|
|
/*
|
|
* Direct children only. A modal mounts *inside* its card, and its panel is
|
|
* position: fixed but still a DOM descendant -- so a plain
|
|
* `.modal-demo-card p` also styles the copy inside the open modal, which
|
|
* repainted the destructive modal body muted grey on its red panel. The
|
|
* card is describing its own blurb here, not everything a component it
|
|
* hosts happens to render.
|
|
*/
|
|
.modal-demo-card > h2 {
|
|
margin: 0;
|
|
font-size: 0.95rem;
|
|
}
|
|
|
|
.modal-demo-card > p {
|
|
margin: 0;
|
|
color: var(--wrn-color-text-muted);
|
|
font-size: 0.82rem;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.modal-demo-form {
|
|
display: grid;
|
|
gap: 0.9rem;
|
|
}
|
|
|
|
.modal-demo-form-error {
|
|
margin: 0;
|
|
color: var(--wrn-color-danger);
|
|
font-size: 0.8rem;
|
|
}
|
|
|
|
.modal-demo-form-error:empty {
|
|
display: none;
|
|
}
|
|
|
|
.modal-demo-submit {
|
|
appearance: none;
|
|
min-height: 2.55rem;
|
|
padding: 0.65rem 1rem;
|
|
color: var(--wrn-color-secondary-contrast);
|
|
background: var(--wrn-color-secondary);
|
|
border: 0;
|
|
border-radius: 0.78rem;
|
|
font: inherit;
|
|
font-size: 0.83rem;
|
|
font-weight: 650;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.modal-demo-toast-demos {
|
|
display: flex;
|
|
align-items: center;
|
|
flex-wrap: wrap;
|
|
gap: 0.5rem;
|
|
margin-top: 1.75rem;
|
|
color: var(--wrn-color-text-muted);
|
|
font-size: 0.82rem;
|
|
}
|
|
|
|
.modal-demo-note {
|
|
color: var(--wrn-color-text-muted);
|
|
}
|
|
|
|
.modal-demo-note em {
|
|
color: var(--wrn-color-text);
|
|
font-style: normal;
|
|
}
|
|
|
|
.modal-demo-toast-demos button {
|
|
appearance: none;
|
|
padding: 0.4rem 0.7rem;
|
|
color: var(--wrn-color-text);
|
|
background: var(--wrn-color-surface-raised);
|
|
border: 1px solid var(--wrn-color-border);
|
|
border-radius: 0.6rem;
|
|
font: inherit;
|
|
font-size: 0.78rem;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.modal-demo-submit:disabled {
|
|
opacity: 0.6;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.modal-demo-terms {
|
|
max-height: 12rem;
|
|
overflow-y: auto;
|
|
padding-right: 0.5rem;
|
|
color: var(--wrn-color-text-muted);
|
|
font-size: 0.85rem;
|
|
line-height: 1.6;
|
|
}
|
|
}
|
|
|
|
view {
|
|
<h1>Modal</h1>
|
|
<p>
|
|
Five common use cases for the shared <code>@wrnexus/ui</code>
|
|
<code>Modal</code> component — themed, accessible, and responsive out
|
|
of the box.
|
|
</p>
|
|
|
|
<div class="modal-demo-grid">
|
|
|
|
<!-- 1. Info-only modal: no footer, dismiss via close button/backdrop/Escape -->
|
|
<div class="modal-demo-card">
|
|
<h2>Info modal</h2>
|
|
<p>No footer, no actions — just content you dismiss.</p>
|
|
<Modal
|
|
title="What's new"
|
|
description="Release notes for this build."
|
|
triggerLabel="View release notes"
|
|
color="info"
|
|
variant="soft"
|
|
size="sm"
|
|
showFooter={false}
|
|
>
|
|
<p>
|
|
This release adds the shared <code>@wrnexus/ui</code>
|
|
<code>Modal</code> component to every app, fully themed to your
|
|
palette and dark/light mode.
|
|
</p>
|
|
</Modal>
|
|
</div>
|
|
|
|
<!-- 2. Standard confirm/cancel modal -->
|
|
<div class="modal-demo-card">
|
|
<h2>Confirmation</h2>
|
|
<p>Cancel/confirm with a primary action.</p>
|
|
<Modal
|
|
title="Save changes?"
|
|
description="You have unsaved edits on this page."
|
|
triggerLabel="Save changes"
|
|
color="primary"
|
|
variant="soft"
|
|
size="md"
|
|
cancelLabel="Discard"
|
|
confirmLabel="Save"
|
|
closeOnConfirm={true}
|
|
@confirm="onSaveConfirmed()"
|
|
>
|
|
<p>Saving will overwrite the previously published version.</p>
|
|
</Modal>
|
|
</div>
|
|
|
|
<!-- 3. Destructive confirmation -->
|
|
<div class="modal-demo-card">
|
|
<h2>Destructive action</h2>
|
|
<p>Danger styling for irreversible actions.</p>
|
|
<Modal
|
|
title="Delete account"
|
|
description="This action cannot be undone."
|
|
triggerLabel="Delete account"
|
|
color="danger"
|
|
variant="solid"
|
|
destructive={true}
|
|
cancelLabel="Keep account"
|
|
confirmLabel="Delete account"
|
|
closeOnConfirm={true}
|
|
@confirm="onDeleteConfirmed()"
|
|
>
|
|
<p>
|
|
All of your data, projects, and billing history will be
|
|
permanently removed.
|
|
</p>
|
|
</Modal>
|
|
</div>
|
|
|
|
<!-- 4. Form modal. The form owns its own submit button, so the Modal
|
|
footer is turned off: @wrnexus/validation binds to form[data-schema]
|
|
and drives the whole flow (validate on input/blur, block submit
|
|
while invalid, POST to /api/invite, emit wrn:success or
|
|
wrn:error). Nothing here re-implements a rule that
|
|
app/schemas/invite.ts already states. -->
|
|
<div class="modal-demo-card">
|
|
<h2>Form modal</h2>
|
|
<p>Real Input fields, validated by the shared schema on both sides.</p>
|
|
<Modal
|
|
title="Invite a teammate"
|
|
description="They will get an email with a link to join."
|
|
triggerLabel="Invite teammate"
|
|
color="secondary"
|
|
variant="soft"
|
|
size="md"
|
|
showFooter={false}
|
|
>
|
|
<form
|
|
class="modal-demo-form"
|
|
data-schema="invite"
|
|
method="post"
|
|
action="/api/invite"
|
|
@wrn:success="onInviteSent(event)"
|
|
@wrn:error="onInviteFailed(event)"
|
|
>
|
|
<Input
|
|
name="email"
|
|
type="email"
|
|
label="Email address"
|
|
placeholder="teammate@company.com"
|
|
autocomplete="email"
|
|
icon="icon-[lucide--mail]"
|
|
required={true}
|
|
helperText="They will get a one-time link to join your workspace."
|
|
/>
|
|
<Input
|
|
name="message"
|
|
label="Note (optional)"
|
|
placeholder="Looking forward to working with you"
|
|
maxlength="140"
|
|
icon="icon-[lucide--message-square]"
|
|
cornerHint="Optional"
|
|
/>
|
|
<p class="modal-demo-form-error" data-error="_form"></p>
|
|
<button type="submit" class="modal-demo-submit">Send invite</button>
|
|
</form>
|
|
</Modal>
|
|
</div>
|
|
|
|
<!-- 5. Large scrollable content, backdrop-click disabled -->
|
|
<div class="modal-demo-card">
|
|
<h2>Scrollable content</h2>
|
|
<p>Larger size, scrollable body, no accidental backdrop dismiss.</p>
|
|
<Modal
|
|
title="Terms of service"
|
|
description="Please review before continuing."
|
|
triggerLabel="Read terms"
|
|
color="primary"
|
|
variant="outline"
|
|
size="lg"
|
|
scrollable={true}
|
|
closeOnBackdrop={false}
|
|
cancelLabel=""
|
|
confirmLabel="I agree"
|
|
closeOnConfirm={true}
|
|
@confirm="onTermsConfirmed()"
|
|
>
|
|
<div class="modal-demo-terms">
|
|
<p>
|
|
This is placeholder terms-of-service copy used to demonstrate a
|
|
tall, scrollable modal body. In a real app this slot would hold
|
|
your actual legal text.
|
|
</p>
|
|
<p>
|
|
1. You agree to use this framework responsibly. 2. Components
|
|
are provided as-is, themed to your app's palette. 3. Scrollable
|
|
modals keep the header and footer fixed while the body scrolls
|
|
independently, so long content never breaks the layout.
|
|
</p>
|
|
<p>
|
|
4. Clicking the backdrop will not close this particular modal —
|
|
that is controlled by the <code>closeOnBackdrop</code> prop,
|
|
set to <code>false</code> here on purpose so an explicit choice
|
|
is required. 5. Pressing Escape still works, since
|
|
<code>closeOnEscape</code> defaults to <code>true</code>.
|
|
</p>
|
|
</div>
|
|
</Modal>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<p class="modal-demo-toast-demos">
|
|
Toast tones:
|
|
<button type="button" @click='toast.success("Everything went through", { title: "Success" })'>success</button>
|
|
<button type="button" @click='toast.error("That did not work", { title: "Error" })'>danger</button>
|
|
<button type="button" @click='onTrialNotice()'>warning + custom icon</button>
|
|
<button type="button" @click='toast.info("Just so you know")'>info</button>
|
|
</p>
|
|
|
|
<p
|
|
class="modal-demo-toast-demos"
|
|
@window:demo:restore-note="restoreNote()"
|
|
@window:demo:discard-note="discardNote()"
|
|
>
|
|
Toast actions:
|
|
<button type="button" @click="onDeleteNote()">delete note (undo)</button>
|
|
<button type="button" @click="onConflict()">conflict (two actions)</button>
|
|
<span class="modal-demo-note">Note: <em data-show="noteText">{noteText}</em><em data-show="!noteText">(deleted)</em></span>
|
|
</p>
|
|
|
|
<!-- One host per page (a real app puts this in the layout). Every
|
|
toast() call anywhere on the page lands here. -->
|
|
<Toaster position="bottom-right" duration={4500} max={4} />
|
|
|
|
}
|
|
}
|