Skip to content

Commit 8de3ef5

Browse files
committed
feat: add form dialog functionality with validation and options
1 parent f84a2d0 commit 8de3ef5

5 files changed

Lines changed: 264 additions & 3 deletions

File tree

packages/core/src/api/services.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,14 @@ export {
3535
navigableInfoDialog,
3636
confirmDialog,
3737
promptDialog,
38+
formDialog,
39+
formDialogRequired,
40+
validateFormDialogFields,
3841
filebrowserDialog,
39-
type NavigableDialogAction
42+
type NavigableDialogAction,
43+
type FormDialogField,
44+
type FormDialogFieldType,
45+
type FormDialogOptions,
4046
} from '../dialogs';
4147
export {
4248
toastInfo,

packages/core/src/api/types.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,10 @@ export type {
3636
export type {
3737
NavigableDialogAction,
3838
FilebrowserDialogMode,
39-
FilebrowserDialogState
39+
FilebrowserDialogState,
40+
FormDialogField,
41+
FormDialogFieldType,
42+
FormDialogOptions,
4043
} from '../dialogs';
4144
export type {
4245
TreeNode,

packages/core/src/core/dialogservice.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,8 +182,13 @@ class DialogService {
182182
}
183183
const stateWithClose = { ...state, close: cleanup };
184184

185+
const dialogLabel =
186+
state && typeof state === 'object' && typeof (state as { label?: unknown }).label === 'string'
187+
? (state as { label: string }).label
188+
: (contribution.label || dialogId);
189+
185190
const template = html`
186-
<wa-dialog label="${contribution.label || dialogId}" open @wa-request-close=${cleanup}>
191+
<wa-dialog label="${dialogLabel}" open @wa-request-close=${cleanup}>
187192
<style>
188193
.dialog-service-content {
189194
display: flex;
Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
import { html, css, PropertyValues } from "lit";
2+
import { customElement, state, property } from "lit/decorators.js";
3+
import { DocksDialogContent } from "../parts/dialog-content";
4+
import { contributionRegistry } from "../core/contributionregistry";
5+
import {
6+
DIALOG_CONTRIBUTION_TARGET,
7+
OK_BUTTON,
8+
CANCEL_BUTTON,
9+
dialogService,
10+
} from "../core/dialogservice";
11+
12+
/** HTML input types supported by {@link formDialog}. */
13+
export type FormDialogFieldType = "text" | "password" | "url" | "email" | "number";
14+
15+
export interface FormDialogField {
16+
/** Key in the returned values record. */
17+
name: string;
18+
label: string;
19+
type?: FormDialogFieldType;
20+
value?: string;
21+
placeholder?: string;
22+
/** When false, empty values are allowed. Defaults to true. */
23+
required?: boolean;
24+
}
25+
26+
export interface FormDialogOptions {
27+
/** Dialog window title (shown on `wa-dialog`). */
28+
label: string;
29+
fields: FormDialogField[];
30+
/** Optional message above the fields (plain text or markdown). */
31+
message?: string;
32+
markdown?: boolean;
33+
}
34+
35+
interface FormDialogState extends FormDialogOptions {
36+
resolve: (values: Record<string, string> | null) => void;
37+
}
38+
39+
@customElement("docks-form-dialog-content")
40+
export class DocksFormDialogContent extends DocksDialogContent {
41+
@property({ attribute: false })
42+
fields: FormDialogField[] = [];
43+
44+
@property({ type: String })
45+
message = "";
46+
47+
@property({ type: Boolean })
48+
markdown = false;
49+
50+
@state()
51+
private values: Record<string, string> = {};
52+
53+
@state()
54+
private validationError = "";
55+
56+
static styles = [
57+
...DocksDialogContent.styles,
58+
css`
59+
.form-fields {
60+
display: flex;
61+
flex-direction: column;
62+
gap: 0.75rem;
63+
min-width: 22rem;
64+
}
65+
66+
.field label {
67+
display: flex;
68+
flex-direction: column;
69+
gap: 0.25rem;
70+
font-size: var(--wa-font-size-s);
71+
}
72+
73+
.field-label {
74+
font-weight: 500;
75+
color: var(--wa-color-text-normal);
76+
}
77+
78+
wa-input {
79+
width: 100%;
80+
}
81+
82+
.validation-error {
83+
color: var(--wa-color-danger-600, #dc2626);
84+
font-size: var(--wa-font-size-s);
85+
}
86+
`,
87+
];
88+
89+
async firstUpdated(changedProperties: PropertyValues) {
90+
super.firstUpdated(changedProperties);
91+
const initial: Record<string, string> = {};
92+
for (const field of this.fields) {
93+
initial[field.name] = field.value ?? "";
94+
}
95+
this.values = initial;
96+
97+
await this.updateComplete;
98+
const input = this.shadowRoot?.querySelector("wa-input");
99+
const inputEl = (input as HTMLElement & { shadowRoot?: ShadowRoot })?.shadowRoot?.querySelector(
100+
"input",
101+
);
102+
inputEl?.focus();
103+
}
104+
105+
getResult(): Record<string, string> {
106+
return { ...this.values };
107+
}
108+
109+
setValidationError(message: string): void {
110+
this.validationError = message;
111+
}
112+
113+
private updateField(name: string, value: string): void {
114+
this.values = { ...this.values, [name]: value };
115+
if (this.validationError) this.validationError = "";
116+
}
117+
118+
private handleKeyDown(event: KeyboardEvent): void {
119+
if (event.key === "Enter") {
120+
event.preventDefault();
121+
this.dispatchEvent(new CustomEvent("dialog-ok", { bubbles: true, composed: true }));
122+
} else if (event.key === "Escape") {
123+
event.preventDefault();
124+
this.dispatchEvent(new CustomEvent("dialog-cancel", { bubbles: true, composed: true }));
125+
}
126+
}
127+
128+
render() {
129+
return html`
130+
${this.message ? this.renderMessage(this.message, this.markdown) : null}
131+
<div class="form-fields" @keydown=${this.handleKeyDown}>
132+
${this.fields.map(
133+
(field) => html`
134+
<div class="field">
135+
<label>
136+
<span class="field-label">${field.label}</span>
137+
<wa-input
138+
type=${field.type ?? "text"}
139+
.value=${this.values[field.name] ?? ""}
140+
placeholder=${field.placeholder ?? ""}
141+
@input=${(event: Event) =>
142+
this.updateField(
143+
field.name,
144+
(event.target as HTMLInputElement).value,
145+
)}
146+
></wa-input>
147+
</label>
148+
</div>
149+
`,
150+
)}
151+
${this.validationError
152+
? html`<div class="validation-error">${this.validationError}</div>`
153+
: null}
154+
</div>
155+
`;
156+
}
157+
}
158+
159+
declare global {
160+
interface HTMLElementTagNameMap {
161+
"docks-form-dialog-content": DocksFormDialogContent;
162+
}
163+
}
164+
165+
function trimValues(values: Record<string, string>): Record<string, string> {
166+
return Object.fromEntries(
167+
Object.entries(values).map(([key, value]) => [key, value.trim()]),
168+
);
169+
}
170+
171+
function findFormDialogContent(): DocksFormDialogContent | null {
172+
return document.querySelector("docks-form-dialog-content");
173+
}
174+
175+
/** Validates required fields; returns an error message or null if valid. */
176+
export function validateFormDialogFields(
177+
fields: FormDialogField[],
178+
values: Record<string, string>,
179+
): string | null {
180+
for (const field of fields) {
181+
if (field.required === false) continue;
182+
if (!values[field.name]?.trim()) {
183+
return `${field.label} is required.`;
184+
}
185+
}
186+
return null;
187+
}
188+
189+
contributionRegistry.registerContribution(DIALOG_CONTRIBUTION_TARGET, {
190+
id: "form",
191+
label: "Form",
192+
buttons: [OK_BUTTON, CANCEL_BUTTON],
193+
component: (state?: FormDialogState) => {
194+
if (!state) {
195+
return html`<div>Error: No form dialog state</div>`;
196+
}
197+
198+
return html`
199+
<docks-form-dialog-content
200+
.message=${state.message ?? ""}
201+
.markdown=${state.markdown ?? false}
202+
.fields=${state.fields}
203+
></docks-form-dialog-content>
204+
`;
205+
},
206+
onButton: async (id: string, result: unknown, state?: FormDialogState) => {
207+
if (!state) {
208+
return true;
209+
}
210+
211+
if (id === "ok") {
212+
const values = trimValues((result ?? {}) as Record<string, string>);
213+
const error = validateFormDialogFields(state.fields, values);
214+
if (error) {
215+
findFormDialogContent()?.setValidationError(error);
216+
return false;
217+
}
218+
state.resolve(values);
219+
} else {
220+
state.resolve(null);
221+
}
222+
223+
return true;
224+
},
225+
});
226+
227+
export function formDialog(options: FormDialogOptions): Promise<Record<string, string> | null> {
228+
return new Promise((resolve) => {
229+
dialogService.open("form", { ...options, resolve });
230+
});
231+
}
232+
233+
export async function formDialogRequired(
234+
options: FormDialogOptions,
235+
): Promise<Record<string, string>> {
236+
const values = await formDialog(options);
237+
if (!values) throw new Error("Cancelled");
238+
return values;
239+
}

packages/core/src/dialogs/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
11
export { promptDialog } from './prompt-dialog';
2+
export {
3+
formDialog,
4+
formDialogRequired,
5+
validateFormDialogFields,
6+
type FormDialogField,
7+
type FormDialogFieldType,
8+
type FormDialogOptions,
9+
} from './form-dialog';
210
export { infoDialog } from './info-dialog';
311
export { confirmDialog } from './confirm-dialog';
412
export { navigableInfoDialog, type NavigableDialogAction } from './navigable-info-dialog';

0 commit comments

Comments
 (0)