Skip to content

Commit d47feac

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 a1a4c6a commit d47feac

11 files changed

Lines changed: 176 additions & 177 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
window.navigation.navigate('/');
2010
});
@@ -37,10 +27,10 @@ class ProfileApp extends HTMLElement {
3727
}
3828

3929
async renderRoute(pathname) {
40-
this.main.replaceChildren();
30+
this.elements.main.replaceChildren();
4131

4232
if (pathname === '/') {
43-
this.main.append(document.createElement('profile-directory'));
33+
this.elements.main.append(document.createElement('profile-directory'));
4434
return;
4535
}
4636

@@ -58,7 +48,7 @@ class ProfileApp extends HTMLElement {
5848
const error = document.createElement('div');
5949
error.className = 'error';
6050
error.textContent = 'Route not found';
61-
this.main.append(error);
51+
this.elements.main.append(error);
6252
}
6353

6454
renderCreate() {
@@ -69,7 +59,7 @@ class ProfileApp extends HTMLElement {
6959
form.addEventListener('profile-created', (event) => {
7060
window.navigation.navigate(`/profile/${event.detail.id}`);
7161
});
72-
this.main.replaceChildren(form);
62+
this.elements.main.replaceChildren(form);
7363
}
7464

7565
async renderProfile(username) {
@@ -78,13 +68,16 @@ class ProfileApp extends HTMLElement {
7868
const error = document.createElement('div');
7969
error.className = 'error';
8070
error.textContent = 'Profile not found';
81-
this.main.append(error);
71+
this.elements.main.append(error);
8272
return;
8373
}
8474
const form = document.createElement('profile-form');
8575
form.state = result;
86-
this.main.append(form);
76+
this.elements.main.append(form);
8777
}
8878
}
8979

90-
customElements.define('profile-app', ProfileApp);
80+
defineCustomElement(ProfileApp, {
81+
name: 'profile-app',
82+
elements: { main: 'main', homeLink: 'homeLink' },
83+
});
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: 10 additions & 17 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}` },
@@ -32,7 +22,7 @@ class ProfileDirectory extends HTMLElement {
3222
this.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)