CreateWorkspaceDialog
CreateWorkspaceDialog is an opinionated Dialog for naming a workspace and choosing its URL. Use it when that standard flow fits; compose Dialog, Input, Avatar and Button yourself when it does not.
Examples
Create a workspace
The slug follows the name until someone edits the slug. After that first edit it stays independent, so an intentional URL is never overwritten by a later name change.
A product-specific URL
title and slugPrefix change the product language around the same workflow. They do not change
the submitted request data: onCreate receives the trimmed name and URL-safe slug in
request.data.
When to use
Use CreateWorkspaceDialog when a product needs the supplied workspace name, slug and initials preview with the supplied interaction rules.
Reach for something else when:
- The creation flow needs different fields, validation or actions — compose Dialog, Input, Avatar and Button yourself instead of bending this opinionated component.
- The person needs to choose the current workspace — use WorkspaceSwitcher. Its
onCreateis the natural place to open this dialog in a product. - The decision blocks another short task but is not workspace creation — use Dialog directly.
Accessibility
- It renders a Dialog, so the panel is
role="dialog"witharia-modal="true"and an accessible name fromtitle. Focus enters and is trapped in the modal; page scroll is locked. - The workspace name and URL fields have visible labels. The avatar preview is derived from the name's initials and updates as the person types; it does not replace the written field labels.
- The name field receives the safe initial focus. Submitting a blank name or slug keeps the dialog
open, shows the field error, focuses the first invalid field, and does not call
onCreate. - A valid native form submission creates exactly one fresh request with trimmed, copied
data, anoperationId, and an initially unaborted ownedsignal. Invalid submission remains available for field validation; while submitting or canceling, duplicate submission is disabled and fields are read-only. - Return a matching terminal acknowledgement from
onCreate:acceptedrequestsonClose,rejectedkeeps the values, focuses the error summary, and permits a retry, andcanceledreturns to editing when no close is pending. A missing handler or result, a thrown error, or a rejected promise becomes an error rather than a successful create. - A normal user close requests
onCloseimmediately. While an operation is pending, user close first aborts the owned signal once and waits for that operation's matchingcanceledacknowledgement; that matchingcanceledacknowledgement requestsonClose; a matchingacceptedacknowledgement requestsonClose, while a matching rejection stays open as an error. An abort notification does not guarantee that application persistence rolled back. - A parent setting controlled
opentofalse(or unmounting) aborts and invalidates pending work; late results are ignored. The next open resets the form.onCloseis a controlled close request, so updateopenin response.returnFocusToforwards its destination resolver to Dialog and uses Dialog's documented eligibility and fallback contract. slugifylowercases, removes accents, collapses non-alphanumerics to hyphens and trims edge hyphens:Ação Globalbecomesacao-global.
API and code
Blade: The released Blade v0.10.0 API snapshot has no component for this catalog entry.
| Name | Type | Required | Description |
|---|---|---|---|
open | boolean | — | Controls visibility. Default `false`. |
onClose | () => void | — | Called when the dialog is dismissed or a creation operation is accepted. |
onCreate | (request: CreateWorkspaceRequest) => CreateWorkspaceResult | Promise<CreateWorkspaceResult> | — | Receives one owned request and explicitly acknowledges its terminal outcome. |
title | string | — | Dialog heading. Default `"Create workspace"`. |
slugPrefix | string | — | Text rendered before the editable slug. Default `"lyra.dev/"`. |
returnFocusTo | () => HTMLElement | null | — | Resolves the focus destination after an accepted close. |
onCreate owns the application persistence decision. It must return an acknowledgement for the
same operationId; the dialog does not make a network request or invent an outcome.
import {
Button,
CreateWorkspaceDialog,
type CreateWorkspaceRequest,
type CreateWorkspaceResult,
} from '@lyra-ds/react';
import { useRef, useState } from 'react';
type Workspace = { id: string; name: string; slug: string };
interface WorkspacePersistence {
createWorkspace(
data: CreateWorkspaceRequest['data'],
options: { signal: AbortSignal },
): Promise<Workspace>;
}
interface WorkspaceCreatorProps {
persistence: WorkspacePersistence;
}
export function WorkspaceCreator({ persistence }: WorkspaceCreatorProps) {
const [open, setOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement | null>(null);
async function onCreate(request: CreateWorkspaceRequest): Promise<CreateWorkspaceResult> {
try {
await persistence.createWorkspace(request.data, { signal: request.signal });
return { operationId: request.operationId, status: 'accepted' };
} catch (error: unknown) {
if (request.signal.aborted) {
return { operationId: request.operationId, status: 'canceled' };
}
return {
operationId: request.operationId,
status: 'rejected',
error: error instanceof Error && error.message ? error.message : 'Could not create workspace.',
};
}
}
return (
<>
<Button ref={triggerRef} onClick={() => setOpen(true)}>
Create workspace
</Button>
<CreateWorkspaceDialog
open={open}
onClose={() => setOpen(false)}
onCreate={onCreate}
returnFocusTo={() => triggerRef.current}
/>
</>
);
}For migration, replace the historical void callback with the complete WorkspaceCreator consumer
above. It passes data as request.data, passes request.signal to persistence, and returns an
explicit result with the matching request.operationId.
- <CreateWorkspaceDialog onCreate={(data) => saveWorkspace(data)} />
+ // WorkspaceCreator above persists request.data with request.signal.
+ // It returns { operationId: request.operationId, status: 'accepted' | 'rejected' | 'canceled' }.
+ <CreateWorkspaceDialog onCreate={onCreate} />x-data="lyraCreateWorkspaceDialog({ … })"
| Option | Type | Required | Description |
|---|---|---|---|
slugPrefix | string | — | Text before the editable slug. Default `lyra.dev/`. |
messages | Partial<LyraCreateWorkspaceMessages> | — | Validation and error copy. |
lyraCreateWorkspaceDialog composes lyraDialog; keep the trigger and modal in one x-data root.
The binding owns the form state and focus, while your application owns persistence. A valid submit
emits lyra:create-workspace with { operationId, name, slug } (trimmed name). Respond with
accept(operationId) or reject(operationId, { fieldErrors: { name?, slug? }, message? }) on the
same Alpine data object. Missing acknowledgement leaves the dialog pending. The button and fields
are locked during submit. reject focuses the role="alert" summary; local validation focuses the
first invalid field. A repeated submit creates no second request.
Closing during submit emits lyra:create-workspace:cancel with { operationId } once and enters
canceling. Abort your request, then call cancel(operationId) to close. A matching accept closes;
a matching reject shows the error. A stale acknowledgement is ignored. An abort signal is a
request to stop work, not proof that persistence rolled back. Closing outside a pending request
returns focus to the trigger through lyraDialog.
Setting open = false externally or removing the component also emits the cancellation event for
an active request; its later acknowledgement is ignored.
<div
x-data="lyraCreateWorkspaceDialog()"
@lyra:create-workspace="
const { operationId, name, slug } = $event.detail;
createWorkspace({ name, slug }).then(
() => accept(operationId),
(error) => reject(operationId, { message: error.message })
)
"
@lyra:create-workspace:cancel="abortWorkspace($event.detail.operationId)"
>
<button id="create-workspace-trigger" type="button" @click="open = true">Create workspace</button>
<div class="lyra-dialog-overlay" x-bind="overlay" style="display: none">
<div class="lyra-dialog" x-bind="panel">
<div class="lyra-dialog__header">
<h2 class="lyra-dialog__title" x-bind="title">Create workspace</h2>
<button class="lyra-dialog__close" x-bind="close" aria-label="Close">×</button>
</div>
<div class="lyra-dialog__body">
<form
id="workspace-create-form"
class="lyra-wscreate"
aria-label="Create workspace"
x-bind="form"
>
<div class="lyra-wscreate__preview">
<span class="lyra-avatar lyra-avatar--lg lyra-avatar--square">
<span aria-hidden="true" x-bind="avatar"></span>
</span>
<span class="lyra-wscreate__preview-hint">The avatar uses the name initials.</span>
</div>
<div
class="lyra-hint lyra-hint--error"
data-lyra-wscreate-error
aria-label="Workspace creation error"
x-bind="errorSummary"
></div>
<div class="lyra-field">
<label class="lyra-label" for="workspace-name">Workspace name</label>
<input
class="lyra-input"
id="workspace-name"
placeholder="Acme Inc"
data-lyra-wscreate-name
x-bind="nameInput"
:aria-describedby="fieldErrors.name ? 'workspace-name-error' : null"
/>
<span
id="workspace-name-error"
class="lyra-hint lyra-hint--error"
x-show="fieldErrors.name"
x-text="fieldErrors.name"
></span>
</div>
<div class="lyra-field">
<label class="lyra-label" for="workspace-slug">URL</label>
<span class="lyra-wscreate__slug">
<span class="lyra-wscreate__slug-prefix" x-bind="slugPrefixBinding"></span>
<input
class="lyra-wscreate__slug-input"
id="workspace-slug"
placeholder="acme-inc"
data-lyra-wscreate-slug
x-bind="slugInput"
:aria-describedby="fieldErrors.slug ? 'workspace-slug-error' : null"
/>
</span>
<span
id="workspace-slug-error"
class="lyra-hint lyra-hint--error"
x-show="fieldErrors.slug"
x-text="fieldErrors.slug"
></span>
<span class="lyra-hint" x-show="!fieldErrors.slug">
Lowercase letters, numbers, and hyphens.
</span>
</div>
</form>
</div>
<div class="lyra-dialog__footer">
<button class="lyra-btn lyra-btn--ghost lyra-btn--md" x-bind="cancelButton">Cancel</button>
<button
class="lyra-btn lyra-btn--primary lyra-btn--md"
form="workspace-create-form"
x-bind="createButton"
>
<span class="lyra-btn__spinner" aria-hidden="true" x-show="pending"></span>
Create workspace
</button>
</div>
</div>
</div>
</div>Use an operation-ID keyed AbortController map in your application. In abortWorkspace(id), abort
that controller and call cancel(id) when its request has settled. For a server validation failure,
pass both fieldErrors and a readable message to reject. The binding never starts a request.
This component composes Dialog, Avatar, Input and Button. Without React, the same classes supply the appearance; the name-to-slug rule, reset behavior, focus and closing behavior are yours to implement:
<div class="lyra-dialog-overlay">
<div class="lyra-dialog" role="dialog" aria-modal="true" aria-labelledby="workspace-title">
<div class="lyra-dialog__header">
<h2 class="lyra-dialog__title" id="workspace-title">Create workspace</h2>
<button class="lyra-dialog__close" type="button" aria-label="Close">×</button>
</div>
<div class="lyra-dialog__body">
<div class="lyra-wscreate">
<div class="lyra-wscreate__preview">
<span class="lyra-avatar lyra-avatar--lg lyra-avatar--square" title="Acme Inc">
<span aria-hidden="true">AI</span>
</span>
<span class="lyra-wscreate__preview-hint">The avatar uses the name initials.</span>
</div>
<div class="lyra-field">
<label class="lyra-label" for="workspace-name">Workspace name</label>
<input class="lyra-input" id="workspace-name" value="Acme Inc" />
</div>
<div class="lyra-field">
<label class="lyra-label" for="workspace-slug">URL</label>
<span class="lyra-wscreate__slug">
<span class="lyra-wscreate__slug-prefix">lyra.dev/</span>
<input class="lyra-wscreate__slug-input" id="workspace-slug" value="acme-inc" />
</span>
<span class="lyra-hint">Lowercase letters, numbers, and hyphens.</span>
</div>
</div>
</div>
<div class="lyra-dialog__footer">
<button class="lyra-btn lyra-btn--ghost lyra-btn--md" type="button">Cancel</button>
<button class="lyra-btn lyra-btn--primary lyra-btn--md" type="button">
Create workspace
</button>
</div>
</div>
</div>