Skip to content

Commit 1f15b95

Browse files
0xDazzerclaude
andcommitted
Add defineCustomElement helper and refactor components
Introduce a defineCustomElement factory that wires up the shadow DOM, template cloning, observed attributes and element references, then refactor the profile components to use it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 07f7d28 commit 1f15b95

11 files changed

Lines changed: 181 additions & 182 deletions

static/components/profile-app.mjs

Lines changed: 12 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,10 @@
11
import { getProfile } from '/api.mjs';
22
import { buildProfileState } from '/shared/profile-domain.mjs';
3-
4-
const template = document.getElementById('profile-app');
3+
import { defineCustomElement } from '/define-custom-element.mjs';
54

65
class ProfileApp extends HTMLElement {
7-
constructor() {
8-
super();
9-
this.attachShadow({ mode: 'open' });
10-
const content = template.content.cloneNode(true);
11-
this.shadowRoot.append(content);
12-
this.main = this.shadowRoot.getElementById('main');
13-
this.homeLink = this.shadowRoot.getElementById('homeLink');
14-
}
15-
166
connectedCallback() {
17-
this.homeLink.addEventListener('click', (event) => {
7+
this.elements.homeLink.addEventListener('click', (event) => {
188
event.preventDefault();
199
this.go('/');
2010
});
@@ -51,10 +41,10 @@ class ProfileApp extends HTMLElement {
5141
}
5242

5343
async renderRoute(pathname) {
54-
this.main.replaceChildren();
44+
this.elements.main.replaceChildren();
5545

5646
if (pathname === '/') {
57-
this.main.append(document.createElement('profile-directory'));
47+
this.elements.main.append(document.createElement('profile-directory'));
5848
return;
5949
}
6050

@@ -69,7 +59,7 @@ class ProfileApp extends HTMLElement {
6959
return;
7060
}
7161

72-
this.main.append(
62+
this.elements.main.append(
7363
Object.assign(document.createElement('div'), {
7464
className: 'error',
7565
textContent: 'Route not found',
@@ -85,13 +75,13 @@ class ProfileApp extends HTMLElement {
8575
form.addEventListener('profile-created', (event) => {
8676
this.go(`/profile/${event.detail.id}`);
8777
});
88-
this.main.replaceChildren(form);
78+
this.elements.main.replaceChildren(form);
8979
}
9080

9181
async renderProfile(username) {
9282
const result = await getProfile(username);
9383
if (!result.ok) {
94-
this.main.append(
84+
this.elements.main.append(
9585
Object.assign(document.createElement('div'), {
9686
className: 'error',
9787
textContent: 'Profile not found',
@@ -101,8 +91,11 @@ class ProfileApp extends HTMLElement {
10191
}
10292
const form = document.createElement('profile-form');
10393
form.state = result;
104-
this.main.append(form);
94+
this.elements.main.append(form);
10595
}
10696
}
10797

108-
customElements.define('profile-app', ProfileApp);
98+
defineCustomElement(ProfileApp, {
99+
name: 'profile-app',
100+
elements: { main: 'main', homeLink: 'homeLink' },
101+
});
Lines changed: 22 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,37 @@
11
import { buildProfileState } from '/shared/profile-domain.mjs';
22
import { createProfile } from '/api.mjs';
3-
4-
const template = document.getElementById('profile-create-dialog');
3+
import { defineCustomElement } from '/define-custom-element.mjs';
54

65
class ProfileCreateDialog extends HTMLElement {
7-
constructor() {
8-
super();
9-
this.attachShadow({ mode: 'open' });
10-
const content = template.content.cloneNode(true);
11-
this.shadowRoot.append(content);
12-
this.dialog = this.shadowRoot.getElementById('dialog');
13-
this.form = this.shadowRoot.getElementById('form');
14-
this.cancelBtn = this.shadowRoot.getElementById('cancel');
15-
this.createBtn = this.shadowRoot.getElementById('create');
16-
this.state = buildProfileState({});
17-
}
6+
state = buildProfileState({});
187

198
connectedCallback() {
20-
this.form.state = this.state;
21-
this.form.editableId = true;
22-
this.form.addEventListener('profile-state-change', (event) => {
9+
this.elements.form.state = this.state;
10+
this.elements.form.editableId = true;
11+
this.elements.form.addEventListener('profile-state-change', (event) => {
2312
this.state = event.detail.state;
24-
this.createBtn.disabled = !this.state.valid;
13+
this.elements.createBtn.disabled = !this.state.valid;
2514
});
26-
this.cancelBtn.addEventListener('click', () => this.close());
27-
this.createBtn.addEventListener('click', () => this.submit());
15+
this.elements.cancelBtn.addEventListener('click', () => this.close());
16+
this.elements.createBtn.addEventListener('click', () => this.submit());
2817
}
2918

3019
open() {
3120
this.state = buildProfileState({});
32-
this.form.state = this.state;
33-
this.createBtn.disabled = !this.state.valid;
34-
this.dialog.showModal();
21+
this.elements.form.state = this.state;
22+
this.elements.createBtn.disabled = !this.state.valid;
23+
this.elements.dialog.showModal();
3524
}
3625

3726
close() {
38-
this.dialog.close();
27+
this.elements.dialog.close();
3928
}
4029

4130
async submit() {
4231
if (!this.state.valid) return;
4332
const result = await createProfile(this.state.profile);
4433
if (!result.ok) {
45-
this.form.serverErrors = result.errors;
34+
this.elements.form.serverErrors = result.errors;
4635
return;
4736
}
4837
this.dispatchEvent(
@@ -56,4 +45,12 @@ class ProfileCreateDialog extends HTMLElement {
5645
}
5746
}
5847

59-
customElements.define('profile-create-dialog', ProfileCreateDialog);
48+
defineCustomElement(ProfileCreateDialog, {
49+
name: 'profile-create-dialog',
50+
elements: {
51+
dialog: 'dialog',
52+
form: 'form',
53+
cancelBtn: 'cancel',
54+
createBtn: 'create',
55+
},
56+
});
Lines changed: 11 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,16 @@
11
import { searchProfiles, deleteProfile } from '/api.mjs';
2-
3-
const template = document.getElementById('profile-directory');
2+
import { defineCustomElement } from '/define-custom-element.mjs';
43

54
class ProfileDirectory extends HTMLElement {
6-
constructor() {
7-
super();
8-
this.attachShadow({ mode: 'open' });
9-
const content = template.content.cloneNode(true);
10-
this.shadowRoot.append(content);
11-
this.search = this.shadowRoot.getElementById('search');
12-
this.list = this.shadowRoot.getElementById('list');
13-
this.createBtn = this.shadowRoot.getElementById('create');
14-
this.query = { name: '', email: '' };
15-
}
5+
query = { name: '', email: '' };
166

177
connectedCallback() {
188
this.load();
19-
this.search.addEventListener('search-change', (event) => {
9+
this.elements.search.addEventListener('search-change', (event) => {
2010
this.query = event.detail;
2111
this.load();
2212
});
23-
this.list.addEventListener('open-profile', (event) => {
13+
this.elements.list.addEventListener('open-profile', (event) => {
2414
this.dispatchEvent(
2515
new CustomEvent('navigate-profile', {
2616
detail: { path: `/profile/${event.detail.id}` },
@@ -29,10 +19,10 @@ class ProfileDirectory extends HTMLElement {
2919
}),
3020
);
3121
});
32-
this.list.addEventListener('delete-profile', (event) =>
22+
this.elements.list.addEventListener('delete-profile', (event) =>
3323
this.removeProfile(event.detail.id),
3424
);
35-
this.createBtn.addEventListener('click', () => {
25+
this.elements.createBtn.addEventListener('click', () => {
3626
this.dispatchEvent(
3727
new CustomEvent('navigate-profile', {
3828
detail: { path: '/new' },
@@ -45,7 +35,7 @@ class ProfileDirectory extends HTMLElement {
4535

4636
async load() {
4737
const { items } = await searchProfiles(this.query);
48-
this.list.items = items;
38+
this.elements.list.items = items;
4939
}
5040

5141
async removeProfile(id) {
@@ -55,4 +45,7 @@ class ProfileDirectory extends HTMLElement {
5545
}
5646
}
5747

58-
customElements.define('profile-directory', ProfileDirectory);
48+
defineCustomElement(ProfileDirectory, {
49+
name: 'profile-directory',
50+
elements: { search: 'search', list: 'list', createBtn: 'create' },
51+
});
Lines changed: 37 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,7 @@
1-
const template = document.getElementById('profile-field');
1+
import { defineCustomElement } from '/define-custom-element.mjs';
22

33
class ProfileField extends HTMLElement {
4-
constructor() {
5-
super();
6-
this.attachShadow({ mode: 'open' });
7-
const content = template.content.cloneNode(true);
8-
this.shadowRoot.append(content);
9-
this.labelEl = this.shadowRoot.getElementById('label');
10-
this.inputEl = this.shadowRoot.getElementById('input');
11-
this.textareaEl = this.shadowRoot.getElementById('textarea');
12-
this.errorEl = this.shadowRoot.getElementById('error');
13-
4+
connectedCallback() {
145
const emit = () => {
156
const event = new CustomEvent('field-change', {
167
detail: { name: this.fieldName, value: this.value },
@@ -19,15 +10,8 @@ class ProfileField extends HTMLElement {
1910
});
2011
this.dispatchEvent(event);
2112
};
22-
this.inputEl.addEventListener('input', emit);
23-
this.textareaEl.addEventListener('input', emit);
24-
}
25-
26-
static get observedAttributes() {
27-
return ['name', 'label', 'type', 'value', 'error', 'multiline', 'disabled'];
28-
}
29-
30-
connectedCallback() {
13+
this.elements.inputEl.addEventListener('input', emit);
14+
this.elements.textareaEl.addEventListener('input', emit);
3115
this.render();
3216
}
3317

@@ -41,28 +25,51 @@ class ProfileField extends HTMLElement {
4125

4226
get value() {
4327
return this.hasAttribute('multiline')
44-
? this.textareaEl.value
45-
: this.inputEl.value;
28+
? this.elements.textareaEl.value
29+
: this.elements.inputEl.value;
4630
}
4731

4832
render() {
4933
const multiline = this.hasAttribute('multiline');
5034
const disabled = this.hasAttribute('disabled');
5135
const id = `field-${this.fieldName}`;
5236

53-
this.inputEl.hidden = multiline;
54-
this.textareaEl.hidden = !multiline;
37+
this.elements.inputEl.hidden = multiline;
38+
this.elements.textareaEl.hidden = !multiline;
5539

56-
const active = multiline ? this.textareaEl : this.inputEl;
57-
this.labelEl.setAttribute('for', id);
40+
const active = multiline ? this.elements.textareaEl : this.elements.inputEl;
41+
this.elements.labelEl.setAttribute('for', id);
5842
active.id = id;
59-
this.labelEl.textContent = this.getAttribute('label') || this.fieldName;
43+
this.elements.labelEl.textContent =
44+
this.getAttribute('label') || this.fieldName;
6045

61-
if (!multiline) this.inputEl.type = this.getAttribute('type') || 'text';
46+
if (!multiline) {
47+
this.elements.inputEl.type = this.getAttribute('type') || 'text';
48+
}
6249
active.value = this.getAttribute('value') || '';
6350
active.disabled = disabled;
64-
this.errorEl.setAttribute('message', this.getAttribute('error') || '');
51+
this.elements.errorEl.setAttribute(
52+
'message',
53+
this.getAttribute('error') || '',
54+
);
6555
}
6656
}
6757

68-
customElements.define('profile-field', ProfileField);
58+
defineCustomElement(ProfileField, {
59+
name: 'profile-field',
60+
observedAttributes: [
61+
'name',
62+
'label',
63+
'type',
64+
'value',
65+
'error',
66+
'multiline',
67+
'disabled',
68+
],
69+
elements: {
70+
labelEl: 'label',
71+
inputEl: 'input',
72+
textareaEl: 'textarea',
73+
errorEl: 'error',
74+
},
75+
});

0 commit comments

Comments
 (0)