cooldev-portfolio / script.js
enzostvs's picture
enzostvs HF Staff
Rework the contact form
fa2e187 verified
// Theme handling
(function initTheme() {
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
const saved = localStorage.getItem('theme');
const html = document.documentElement;
const toggle = document.getElementById('theme-toggle');
const knob = document.getElementById('theme-toggle-knob');
const sunIcon = toggle?.querySelector('[data-feather="sun"]');
const moonIcon = toggle?.querySelector('[data-feather="moon"]');
const applyTheme = (mode) => {
if (mode === 'light') {
html.classList.add('light');
// Move knob to the right for light mode
if (knob) {
knob.style.left = 'calc(100% - 1.25rem - 0.125rem)'; // Right position (w-5 = 1.25rem, top-0.5 = 0.125rem)
}
// Update icons
if (moonIcon && sunIcon) {
moonIcon.style.opacity = '0';
moonIcon.style.transform = 'rotate(90deg)';
sunIcon.style.opacity = '1';
sunIcon.style.transform = 'rotate(0deg)';
}
} else {
html.classList.remove('light');
// Move knob to the left for dark mode
if (knob) {
knob.style.left = '0.125rem'; // Left position (top-0.5 = 0.125rem)
}
// Update icons
if (moonIcon && sunIcon) {
moonIcon.style.opacity = '1';
moonIcon.style.transform = 'rotate(0deg)';
sunIcon.style.opacity = '0';
sunIcon.style.transform = 'rotate(-90deg)';
}
}
};
const initial = saved || (prefersDark ? 'dark' : 'dark');
applyTheme(initial);
toggle?.addEventListener('click', () => {
const isLight = html.classList.toggle('light');
localStorage.setItem('theme', isLight ? 'light' : 'dark');
applyTheme(isLight ? 'light' : 'dark');
});
})();
// Mobile menu
const menuToggle = document.getElementById('menu-toggle');
const mobileMenu = document.getElementById('mobile-menu');
menuToggle?.addEventListener('click', () => {
mobileMenu?.classList.toggle('hidden');
const icon = menuToggle.querySelector('[data-feather]');
if (icon) icon.setAttribute('data-feather', mobileMenu?.classList.contains('hidden') ? 'menu' : 'x');
});
mobileMenu?.querySelectorAll('a').forEach(a => a.addEventListener('click', () => mobileMenu.classList.add('hidden')));
// Smooth anchor offsets for fixed header (optional - using scroll-margin in CSS not needed with pt-20)
// Scroll progress + back to top
const backToTop = document.getElementById('back-to-top');
window.addEventListener('scroll', () => {
const scrolled = window.scrollY;
backToTop?.classList.toggle('opacity-100', scrolled > 400);
backToTop?.classList.toggle('pointer-events-auto', scrolled > 400);
});
backToTop?.addEventListener('click', () => window.scrollTo({ top: 0, behavior: 'smooth' }));
// Contact form with validation
const form = document.getElementById('contact-form');
const status = document.getElementById('form-status');
const statusIcon = document.getElementById('status-icon');
const statusText = document.getElementById('status-text');
const submitBtn = document.getElementById('submit-btn');
const submitText = document.getElementById('submit-text');
const resetBtn = document.getElementById('reset-btn');
// Reset button functionality
resetBtn?.addEventListener('click', () => {
form.reset();
// Clear custom component errors
document.querySelectorAll('custom-input, custom-textarea').forEach(el => {
if (el.clearError) el.clearError();
});
// Clear radio button selections
document.querySelectorAll('input[type="radio"], input[type="checkbox"]').forEach(input => {
input.checked = false;
});
statusText.textContent = '';
statusIcon.innerHTML = '';
feather.replace();
});
function validateForm() {
let isValid = true;
// Clear all errors first
document.querySelectorAll('custom-input, custom-textarea').forEach(el => {
if (el.clearError) el.clearError();
});
// Get custom components
const nameInput = document.querySelector('custom-input#name');
const emailInput = document.querySelector('custom-input#email');
const subjectInput = document.querySelector('custom-input#subject');
const messageInput = document.querySelector('custom-textarea#message');
const privacyCheckbox = document.getElementById('privacy');
// Validate name
if (!nameInput || !nameInput.value.trim()) {
if (nameInput) nameInput.setError('Please enter your name');
isValid = false;
}
// Validate email
const email = emailInput?.value.trim();
if (!emailInput || !email) {
if (emailInput) emailInput.setError('Please enter your email');
isValid = false;
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
if (emailInput) emailInput.setError('Please enter a valid email address');
isValid = false;
}
// Validate subject
if (!subjectInput || !subjectInput.value.trim()) {
if (subjectInput) subjectInput.setError('Please enter a subject');
isValid = false;
}
// Validate message
const message = messageInput?.value.trim();
if (!messageInput || !message) {
if (messageInput) messageInput.setError('Please enter your message');
isValid = false;
} else if (message.length < 10) {
if (messageInput) messageInput.setError('Message must be at least 10 characters long');
isValid = false;
}
// Validate privacy checkbox
if (!privacyCheckbox || !privacyCheckbox.checked) {
showStatus('Please agree to the privacy policy', 'error');
isValid = false;
}
return isValid;
}
function collectFormData() {
const projectType = document.querySelector('input[name="project-type"]:checked');
const budget = document.querySelector('input[name="budget"]:checked');
return {
name: document.querySelector('custom-input#name')?.value || '',
email: document.querySelector('custom-input#email')?.value || '',
subject: document.querySelector('custom-input#subject')?.value || '',
message: document.querySelector('custom-textarea#message')?.value || '',
projectType: projectType?.value || '',
budget: budget?.value || '',
privacy: document.getElementById('privacy')?.checked || false
};
}
function showStatus(message, type = 'info') {
statusText.textContent = message;
status.classList.remove('text-red-400', 'text-emerald-400', 'text-zinc-400');
if (type === 'error') {
status.classList.add('text-red-400');
statusIcon.innerHTML = '<i data-feather="alert-circle" class="w-4 h-4"></i>';
} else if (type === 'success') {
status.classList.add('text-emerald-400');
statusIcon.innerHTML = '<i data-feather="check-circle" class="w-4 h-4"></i>';
} else {
status.classList.add('text-zinc-400');
statusIcon.innerHTML = '';
}
feather.replace();
}
// Handle form submission
form?.addEventListener('submit', async (e) => {
e.preventDefault();
if (!validateForm()) {
return;
}
// Reset status
showStatus('Sending your message...', 'info');
// Disable form and show loading state
submitBtn.disabled = true;
submitText.textContent = 'Sending...';
submitBtn.classList.add('opacity-75');
try {
// Simulate form submission (replace with actual endpoint)
const data = collectFormData();
// Simulate API call delay
await new Promise(resolve => setTimeout(resolve, 1500));
// For demo purposes, just log the data
console.log('Form submitted:', data);
// Success
showStatus('Message sent successfully! I\'ll get back to you soon.', 'success');
// Reset form after a delay
setTimeout(() => {
form.reset();
document.querySelectorAll('custom-input, custom-textarea').forEach(el => {
if (el.clearError) el.clearError();
});
statusText.textContent = '';
statusIcon.innerHTML = '';
feather.replace();
}, 3000);
} catch (error) {
console.error('Form submission error:', error);
showStatus('Something went wrong. Please try again or email me directly.', 'error');
} finally {
// Reset button state
submitBtn.disabled = false;
submitText.textContent = 'Send Message';
submitBtn.classList.remove('opacity-75');
}
});
// Projects data
const projects = [
{
title: 'SaaS Billing Dashboard',
desc: 'Multi-tenant analytics dashboard with role-based access, charts and export.',
tags: ['React', 'TypeScript', 'Tailwind', 'Recharts'],
demo: '#',
code: '#',
cover: 'linear-gradient(135deg, rgb(var(--color-primary-500)), rgb(var(--color-secondary-500)))'
},
{
title: 'Design System',
desc: 'Token-driven component library with docs site and a11y testing.',
tags: ['React', 'Storybook', 'A11y', 'Radix'],
demo: '#',
code: '#',
cover: 'linear-gradient(135deg, rgb(var(--color-tertiary-500)), rgb(var(--color-primary-500)))'
},
{
title: 'E‑commerce Frontend',
desc: 'Headless storefront with SSR, cart, and payments integration.',
tags: ['Next.js', 'TypeScript', 'Tailwind', 'Stripe'],
demo: '#',
code: '#',
cover: 'linear-gradient(135deg, rgb(var(--color-secondary-500)), rgb(var(--color-tertiary-500)))'
},
{
title: 'Real‑time Chat',
desc: 'WebSocket chat with presence, typing indicators, and message threads.',
tags: ['Vue', 'Pinia', 'WebSocket', 'Node.js'],
demo: '#',
code: '#',
cover: 'linear-gradient(135deg, rgb(var(--color-primary-500)), rgb(var(--color-tertiary-500)))'
},
{
title: 'Perf Audit & Optimization',
desc: 'Improved LCP, CLS and TTI; bundle splitting and image optimization.',
tags: ['Lighthouse', 'Vite', 'SSR', 'Bundle'],
demo: '#',
code: '#',
cover: 'linear-gradient(135deg, rgb(var(--color-tertiary-500)), rgb(var(--color-secondary-500)))'
},
{
title: 'Headless CMS Frontend',
desc: 'Content-driven site with ISR, preview mode and i18n.',
tags: ['Next.js', 'CMS', 'ISR', 'i18n'],
demo: '#',
code: '#',
cover: 'linear-gradient(135deg, rgb(var(--color-secondary-500)), rgb(var(--color-primary-500)))'
}
];
// Render project cards
const grid = document.getElementById('projects-grid');
if (grid) {
projects.forEach(p => {
const el = document.createElement('article');
el.className = 'group relative overflow-hidden rounded-2xl border border-white/10 bg-[color:rgb(var(--bg)_/_0.7)] hover:border-[color:rgb(var(--color-primary-500)_/_0.35)] transition-colors';
el.innerHTML = `
<div class="relative h-40 w-full" style="background:${p.cover}">
<div class="absolute inset-0 bg-[color:rgb(var(--bg)_/_0.25)]"></div>
<div class="absolute inset-0 opacity-0 group-hover:opacity-100 transition flex items-center justify-center gap-3">
<a href="${p.demo}" target="_blank" class="btn-primary">Live demo</a>
<a href="${p.code}" target="_blank" class="btn-ghost">Source</a>
</div>
</div>
<div class="p-5">
<h3 class="font-semibold text-zinc-100">${p.title}</h3>
<p class="mt-1 text-sm text-zinc-400">${p.desc}</p>
<div class="mt-4 flex flex-wrap gap-2">
${p.tags.map(t => `<span class="chip">${t}</span>`).join('')}
</div>
</div>
`;
grid.appendChild(el);
});
}
// Year
document.getElementById('year').textContent = new Date().getFullYear();
// Optional: Download CV button (placeholder)
document.getElementById('download-cv')?.addEventListener('click', (e) => {
e.preventDefault();
const blob = new Blob([`CoolDev — Frontend Engineer\nResume placeholder`], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'CoolDev-Resume.txt';
a.click();
URL.revokeObjectURL(url);
});
// Typing animation for code
function initTypingAnimation() {
const codeContainer = document.getElementById('typing-code');
if (!codeContainer) return;
const codeLines = [
{
text: 'const theme = "dark"',
chars: [
{ type: 'keyword', text: 'const' },
{ type: 'text', text: ' ' },
{ type: 'text', text: 'theme ' },
{ type: 'operator', text: '=' },
{ type: 'text', text: ' ' },
{ type: 'string', text: '"dark"' }
]
},
{
text: 'function Button({ children, ...props }) {',
chars: [
{ type: 'keyword', text: 'function' },
{ type: 'text', text: ' ' },
{ type: 'function', text: 'Button' },
{ type: 'punctuation', text: '(' },
{ type: 'punctuation', text: '{' },
{ type: 'text', text: ' children, ' },
{ type: 'operator', text: '...' },
{ type: 'text', text: 'props ' },
{ type: 'punctuation', text: '}' },
{ type: 'punctuation', text: ')' },
{ type: 'text', text: ' ' },
{ type: 'punctuation', text: '{' }
]
},
{
text: ' return (',
chars: [
{ type: 'keyword', text: 'return' },
{ type: 'text', text: ' (' }
]
},
{
text: ' <button className="btn-primary" {...props}>',
chars: [
{ type: 'tag', text: '<' },
{ type: 'tag', text: 'button' },
{ type: 'text', text: ' ' },
{ type: 'attr', text: 'className' },
{ type: 'operator', text: '=' },
{ type: 'string', text: '"btn-primary"' },
{ type: 'text', text: ' ' },
{ type: 'operator', text: '{' },
{ type: 'operator', text: '.' },
{ type: 'operator', text: '.' },
{ type: 'operator', text: '}' },
{ type: 'tag', text: '>' }
]
},
{
text: ' {children}',
chars: [
{ type: 'punctuation', text: '{' },
{ type: 'text', text: 'children' },
{ type: 'punctuation', text: '}' }
]
},
{
text: ' </button>',
chars: [
{ type: 'tag', text: '<' },
{ type: 'operator', text: '/' },
{ type: 'tag', text: 'button' },
{ type: 'tag', text: '>' }
]
},
{
text: ' )',
chars: [
{ type: 'punctuation', text: ')' }
]
},
{
text: '}',
chars: [
{ type: 'punctuation', text: '}' }
]
}
];
let currentLine = 0;
let currentChar = 0;
let isTyping = true;
let timeoutId;
const cursor = document.querySelector('.typing-cursor');
// Create a line container for better formatting
let currentLineContainer = null;
function typeCharacter() {
if (currentLine >= codeLines.length) {
// Finished typing all lines
isTyping = false;
// Wait 5 seconds, then restart
setTimeout(() => {
restartTyping();
}, 5000);
return;
}
const line = codeLines[currentLine];
const char = line.chars[currentChar];
// Create line container on first character of each line
if (currentChar === 0) {
currentLineContainer = document.createElement('div');
currentLineContainer.style.whiteSpace = 'pre';
currentLineContainer.style.fontFamily = 'inherit';
}
// Create and append the character element
const charElement = document.createElement('span');
charElement.className = char.type !== 'text' ? char.type : '';
charElement.textContent = char.text;
// Handle indentation by adjusting margin on the line container
if (currentChar === 0) {
const indentLevel = Math.floor((line.text.match(/^(\s*)/)[1].length) / 2);
currentLineContainer.style.marginLeft = (indentLevel * 12) + 'px';
}
currentLineContainer.appendChild(charElement);
codeContainer.appendChild(currentLineContainer);
currentChar++;
// Calculate typing speed (varies by character type)
let delay = 50; // default delay
if (char.type === 'punctuation') delay = 100;
else if (char.type === 'operator') delay = 80;
else if (char.type === 'keyword' || char.type === 'function') delay = 120;
else if (char.type === 'string') delay = 150;
else if (char.text === ' ') delay = 30;
// Add some random variation
delay += Math.random() * 50;
timeoutId = setTimeout(typeCharacter, delay);
// If we finished the current line, move to next line
if (currentChar >= line.chars.length) {
currentLine++;
currentChar = 0;
currentLineContainer = null;
// Add small gap between lines
if (currentLine < codeLines.length) {
const lineBreak = document.createElement('div');
lineBreak.style.height = '4px';
lineBreak.style.width = '100%';
codeContainer.appendChild(lineBreak);
}
}
}
function restartTyping() {
// Clear existing content
codeContainer.innerHTML = '';
// Reset counters
currentLine = 0;
currentChar = 0;
isTyping = true;
currentLineContainer = null;
// Clear any existing timeout
if (timeoutId) {
clearTimeout(timeoutId);
}
// Start typing again
setTimeout(typeCharacter, 1000); // 1 second delay before restart
}
// Start the typing animation
setTimeout(typeCharacter, 2000); // 2 second initial delay
// Hide cursor when not typing (during wait periods)
setInterval(() => {
if (cursor && isTyping) {
cursor.classList.toggle('hide');
}
}, 500);
}
// Initialize typing animation when page loads
document.addEventListener('DOMContentLoaded', initTypingAnimation);
// Custom Input Component
class CustomInput extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.value = '';
}
static get observedAttributes() {
return ['placeholder', 'type', 'name', 'required', 'value'];
}
connectedCallback() {
this.render();
}
attributeChangedCallback() {
this.render();
}
render() {
const placeholder = this.getAttribute('placeholder') || '';
const type = this.getAttribute('type') || 'text';
const name = this.getAttribute('name') || '';
const required = this.hasAttribute('required');
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
width: 100%;
}
.input-wrapper {
position: relative;
width: 100%;
}
.input-field {
width: 100%;
padding: 14px 16px 14px 44px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
color: rgb(var(--fg));
font-size: 14px;
outline: none;
transition: all 0.2s ease;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1);
}
.input-field:focus {
border-color: rgba(var(--color-primary-500), 0.6);
box-shadow: 0 0 0 3px rgba(var(--color-primary-500), 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.08);
}
.input-field::placeholder {
color: rgba(161, 161, 170, 0.6);
transition: color 0.2s ease;
}
.input-field:focus::placeholder {
color: rgba(161, 161, 170, 0.4);
}
.input-field.error {
border-color: rgb(239, 68, 68);
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.1);
}
.input-icon {
position: absolute;
left: 14px;
top: 50%;
transform: translateY(-50%);
width: 16px;
height: 16px;
color: rgba(161, 161, 170, 0.8);
transition: all 0.2s ease;
pointer-events: none;
}
.input-field:focus + .input-icon {
color: rgba(var(--color-primary-500), 0.8);
}
.error-text {
margin-top: 6px;
font-size: 12px;
color: rgb(239, 68, 68);
opacity: 0;
transform: translateY(-4px);
transition: all 0.2s ease;
}
.error-text.show {
opacity: 1;
transform: translateY(0);
}
/* Light theme styles */
:host(.light) .input-field {
background: rgba(255, 255, 255, 0.8);
border-color: rgba(15, 23, 42, 0.15);
color: rgb(15, 23, 42);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5);
}
:host(.light) .input-field:focus {
background: rgba(255, 255, 255, 0.95);
border-color: rgba(var(--color-primary-500), 0.6);
box-shadow: 0 0 0 3px rgba(var(--color-primary-500), 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.5);
}
:host(.light) .input-field::placeholder {
color: rgba(100, 116, 139, 0.8);
}
:host(.light) .input-icon {
color: rgba(100, 116, 139, 0.8);
}
</style>
<div class="input-wrapper">
<input
type="${type}"
name="${name}"
placeholder="${placeholder}"
${required ? 'required' : ''}
class="input-field"
/>
<div class="input-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<path d="m9 12 2 2 4-4"></path>
</svg>
</div>
<div class="error-text"></div>
</div>
`;
this.value = this.getAttribute('value') || '';
this.setupEventListeners();
this.updateTheme();
}
setupEventListeners() {
const input = this.shadowRoot.querySelector('.input-field');
const errorText = this.shadowRoot.querySelector('.error-text');
if (input) {
input.addEventListener('input', (e) => {
this.value = e.target.value;
this.dispatchEvent(new CustomEvent('input', { detail: { value: this.value } }));
this.clearError();
});
input.addEventListener('blur', () => {
this.dispatchEvent(new CustomEvent('blur', { detail: { value: this.value } }));
});
input.addEventListener('focus', () => {
this.dispatchEvent(new CustomEvent('focus', { detail: { value: this.value } }));
});
}
}
updateTheme() {
const html = document.documentElement;
if (html.classList.contains('light')) {
this.shadowRoot.host.classList.add('light');
} else {
this.shadowRoot.host.classList.remove('light');
}
}
setError(message) {
const input = this.shadowRoot.querySelector('.input-field');
const errorText = this.shadowRoot.querySelector('.error-text');
if (input) input.classList.add('error');
if (errorText) {
errorText.textContent = message;
errorText.classList.add('show');
}
}
clearError() {
const input = this.shadowRoot.querySelector('.input-field');
const errorText = this.shadowRoot.querySelector('.error-text');
if (input) input.classList.remove('error');
if (errorText) {
errorText.classList.remove('show');
}
}
get value() {
return this._value || '';
}
set value(val) {
this._value = val;
const input = this.shadowRoot?.querySelector('.input-field');
if (input) {
input.value = val;
}
}
}
// Custom Textarea Component
class CustomTextarea extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.value = '';
}
static get observedAttributes() {
return ['placeholder', 'name', 'required', 'rows', 'value'];
}
connectedCallback() {
this.render();
}
attributeChangedCallback() {
this.render();
}
render() {
const placeholder = this.getAttribute('placeholder') || '';
const name = this.getAttribute('name') || '';
const required = this.hasAttribute('required');
const rows = this.getAttribute('rows') || '4';
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
width: 100%;
}
.textarea-wrapper {
position: relative;
width: 100%;
}
.textarea-field {
width: 100%;
min-height: 120px;
padding: 14px 16px 14px 16px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
color: rgb(var(--fg));
font-size: 14px;
outline: none;
transition: all 0.2s ease;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1);
resize: vertical;
font-family: inherit;
line-height: 1.5;
}
.textarea-field:focus {
border-color: rgba(var(--color-primary-500), 0.6);
box-shadow: 0 0 0 3px rgba(var(--color-primary-500), 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.08);
}
.textarea-field::placeholder {
color: rgba(161, 161, 170, 0.6);
transition: color 0.2s ease;
}
.textarea-field:focus::placeholder {
color: rgba(161, 161, 170, 0.4);
}
.textarea-field.error {
border-color: rgb(239, 68, 68);
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.1);
}
.error-text {
margin-top: 6px;
font-size: 12px;
color: rgb(239, 68, 68);
opacity: 0;
transform: translateY(-4px);
transition: all 0.2s ease;
}
.error-text.show {
opacity: 1;
transform: translateY(0);
}
/* Light theme styles */
:host(.light) .textarea-field {
background: rgba(255, 255, 255, 0.8);
border-color: rgba(15, 23, 42, 0.15);
color: rgb(15, 23, 42);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5);
}
:host(.light) .textarea-field:focus {
background: rgba(255, 255, 255, 0.95);
border-color: rgba(var(--color-primary-500), 0.6);
box-shadow: 0 0 0 3px rgba(var(--color-primary-500), 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.5);
}
:host(.light) .textarea-field::placeholder {
color: rgba(100, 116, 139, 0.8);
}
</style>
<div class="textarea-wrapper">
<textarea
name="${name}"
placeholder="${placeholder}"
${required ? 'required' : ''}
rows="${rows}"
class="textarea-field"
></textarea>
<div class="error-text"></div>
</div>
`;
this.value = this.getAttribute('value') || '';
this.setupEventListeners();
this.updateTheme();
}
setupEventListeners() {
const textarea = this.shadowRoot.querySelector('.textarea-field');
const errorText = this.shadowRoot.querySelector('.error-text');
if (textarea) {
textarea.addEventListener('input', (e) => {
this.value = e.target.value;
this.dispatchEvent(new CustomEvent('input', { detail: { value: this.value } }));
this.clearError();
});
textarea.addEventListener('blur', () => {
this.dispatchEvent(new CustomEvent('blur', { detail: { value: this.value } }));
});
textarea.addEventListener('focus', () => {
this.dispatchEvent(new CustomEvent('focus', { detail: { value: this.value } }));
});
}
}
updateTheme() {
const html = document.documentElement;
if (html.classList.contains('light')) {
this.shadowRoot.host.classList.add('light');
} else {
this.shadowRoot.host.classList.remove('light');
}
}
setError(message) {
const textarea = this.shadowRoot.querySelector('.textarea-field');
const errorText = this.shadowRoot.querySelector('.error-text');
if (textarea) textarea.classList.add('error');
if (errorText) {
errorText.textContent = message;
errorText.classList.add('show');
}
}
clearError() {
const textarea = this.shadowRoot.querySelector('.textarea-field');
const errorText = this.shadowRoot.querySelector('.error-text');
if (textarea) textarea.classList.remove('error');
if (errorText) {
errorText.classList.remove('show');
}
}
get value() {
return this._value || '';
}
set value(val) {
this._value = val;
const textarea = this.shadowRoot?.querySelector('.textarea-field');
if (textarea) {
textarea.value = val;
}
}
}
// Register the custom elements
customElements.define('custom-input', CustomInput);
customElements.define('custom-textarea', CustomTextarea);
// Theme change observer
const themeObserver = new MutationObserver(() => {
document.querySelectorAll('custom-input, custom-textarea').forEach(el => {
if (el.updateTheme) el.updateTheme();
});
});
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class']
});
// Custom Input Component
class CustomInput extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.value = '';
}
static get observedAttributes() {
return ['placeholder', 'type', 'name', 'required', 'value'];
}
connectedCallback() {
this.render();
}
attributeChangedCallback() {
this.render();
}
render() {
const placeholder = this.getAttribute('placeholder') || '';
const type = this.getAttribute('type') || 'text';
const name = this.getAttribute('name') || '';
const required = this.hasAttribute('required');
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
width: 100%;
}
.input-wrapper {
position: relative;
width: 100%;
}
.input-field {
width: 100%;
padding: 14px 16px 14px 44px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
color: rgb(var(--fg));
font-size: 14px;
outline: none;
transition: all 0.2s ease;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1);
}
.input-field:focus {
border-color: rgba(var(--color-primary-500), 0.6);
box-shadow: 0 0 0 3px rgba(var(--color-primary-500), 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.08);
}
.input-field::placeholder {
color: rgba(161, 161, 170, 0.6);
transition: color 0.2s ease;
}
.input-field:focus::placeholder {
color: rgba(161, 161, 170, 0.4);
}
.input-field.error {
border-color: rgb(239, 68, 68);
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.1);
}
.input-icon {
position: absolute;
left: 14px;
top: 50%;
transform: translateY(-50%);
width: 16px;
height: 16px;
color: rgba(161, 161, 170, 0.8);
transition: all 0.2s ease;
pointer-events: none;
}
.input-field:focus + .input-icon {
color: rgba(var(--color-primary-500), 0.8);
}
.error-text {
margin-top: 6px;
font-size: 12px;
color: rgb(239, 68, 68);
opacity: 0;
transform: translateY(-4px);
transition: all 0.2s ease;
}
.error-text.show {
opacity: 1;
transform: translateY(0);
}
/* Light theme styles */
:host(.light) .input-field {
background: rgba(255, 255, 255, 0.8);
border-color: rgba(15, 23, 42, 0.15);
color: rgb(15, 23, 42);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5);
}
:host(.light) .input-field:focus {
background: rgba(255, 255, 255, 0.95);
border-color: rgba(var(--color-primary-500), 0.6);
box-shadow: 0 0 0 3px rgba(var(--color-primary-500), 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.5);
}
:host(.light) .input-field::placeholder {
color: rgba(100, 116, 139, 0.8);
}
:host(.light) .input-icon {
color: rgba(100, 116, 139, 0.8);
}
</style>
<div class="input-wrapper">
<input
type="${type}"
name="${name}"
placeholder="${placeholder}"
${required ? 'required' : ''}
class="input-field"
/>
<div class="input-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<path d="m9 12 2 2 4-4"></path>
</svg>
</div>
<div class="error-text"></div>
</div>
`;
this.value = this.getAttribute('value') || '';
this.setupEventListeners();
this.updateTheme();
}
setupEventListeners() {
const input = this.shadowRoot.querySelector('.input-field');
const errorText = this.shadowRoot.querySelector('.error-text');
if (input) {
input.addEventListener('input', (e) => {
this.value = e.target.value;
this.dispatchEvent(new CustomEvent('input', { detail: { value: this.value } }));
this.clearError();
});
input.addEventListener('blur', () => {
this.dispatchEvent(new CustomEvent('blur', { detail: { value: this.value } }));
});
input.addEventListener('focus', () => {
this.dispatchEvent(new CustomEvent('focus', { detail: { value: this.value } }));
});
}
}
updateTheme() {
const html = document.documentElement;
if (html.classList.contains('light')) {
this.shadowRoot.host.classList.add('light');
} else {
this.shadowRoot.host.classList.remove('light');
}
}
setError(message) {
const input = this.shadowRoot.querySelector('.input-field');
const errorText = this.shadowRoot.querySelector('.error-text');
if (input) input.classList.add('error');
if (errorText) {
errorText.textContent = message;
errorText.classList.add('show');
}
}
clearError() {
const input = this.shadowRoot.querySelector('.input-field');
const errorText = this.shadowRoot.querySelector('.error-text');
if (input) input.classList.remove('error');
if (errorText) {
errorText.classList.remove('show');
}
}
get value() {
return this._value || '';
}
set value(val) {
this._value = val;
const input = this.shadowRoot?.querySelector('.input-field');
if (input) {
input.value = val;
}
}
}
// Custom Textarea Component
class CustomTextarea extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.value = '';
}
static get observedAttributes() {
return ['placeholder', 'name', 'required', 'rows', 'value'];
}
connectedCallback() {
this.render();
}
attributeChangedCallback() {
this.render();
}
render() {
const placeholder = this.getAttribute('placeholder') || '';
const name = this.getAttribute('name') || '';
const required = this.hasAttribute('required');
const rows = this.getAttribute('rows') || '4';
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
width: 100%;
}
.textarea-wrapper {
position: relative;
width: 100%;
}
.textarea-field {
width: 100%;
min-height: 120px;
padding: 14px 16px 14px 16px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
color: rgb(var(--fg));
font-size: 14px;
outline: none;
transition: all 0.2s ease;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1);
resize: vertical;
font-family: inherit;
line-height: 1.5;
}
.textarea-field:focus {
border-color: rgba(var(--color-primary-500), 0.6);
box-shadow: 0 0 0 3px rgba(var(--color-primary-500), 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.08);
}
.textarea-field::placeholder {
color: rgba(161, 161, 170, 0.6);
transition: color 0.2s ease;
}
.textarea-field:focus::placeholder {
color: rgba(161, 161, 170, 0.4);
}
.textarea-field.error {
border-color: rgb(239, 68, 68);
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.1);
}
.error-text {
margin-top: 6px;
font-size: 12px;
color: rgb(239, 68, 68);
opacity: 0;
transform: translateY(-4px);
transition: all 0.2s ease;
}
.error-text.show {
opacity: 1;
transform: translateY(0);
}
/* Light theme styles */
:host(.light) .textarea-field {
background: rgba(255, 255, 255, 0.8);
border-color: rgba(15, 23, 42, 0.15);
color: rgb(15, 23, 42);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5);
}
:host(.light) .textarea-field:focus {
background: rgba(255, 255, 255, 0.95);
border-color: rgba(var(--color-primary-500), 0.6);
box-shadow: 0 0 0 3px rgba(var(--color-primary-500), 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.5);
}
:host(.light) .textarea-field::placeholder {
color: rgba(100, 116, 139, 0.8);
}
</style>
<div class="textarea-wrapper">
<textarea
name="${name}"
placeholder="${placeholder}"
${required ? 'required' : ''}
rows="${rows}"
class="textarea-field"
></textarea>
<div class="error-text"></div>
</div>
`;
this.value = this.getAttribute('value') || '';
this.setupEventListeners();
this.updateTheme();
}
setupEventListeners() {
const textarea = this.shadowRoot.querySelector('.textarea-field');
const errorText = this.shadowRoot.querySelector('.error-text');
if (textarea) {
textarea.addEventListener('input', (e) => {
this.value = e.target.value;
this.dispatchEvent(new CustomEvent('input', { detail: { value: this.value } }));
this.clearError();
});
textarea.addEventListener('blur', () => {
this.dispatchEvent(new CustomEvent('blur', { detail: { value: this.value } }));
});
textarea.addEventListener('focus', () => {
this.dispatchEvent(new CustomEvent('focus', { detail: { value: this.value } }));
});
}
}
updateTheme() {
const html = document.documentElement;
if (html.classList.contains('light')) {
this.shadowRoot.host.classList.add('light');
} else {
this.shadowRoot.host.classList.remove('light');
}
}
setError(message) {
const textarea = this.shadowRoot.querySelector('.textarea-field');
const errorText = this.shadowRoot.querySelector('.error-text');
if (textarea) textarea.classList.add('error');
if (errorText) {
errorText.textContent = message;
errorText.classList.add('show');
}
}
clearError() {
const textarea = this.shadowRoot.querySelector('.textarea-field');
const errorText = this.shadowRoot.querySelector('.error-text');
if (textarea) textarea.classList.remove('error');
if (errorText) {
errorText.classList.remove('show');
}
}
get value() {
return this._value || '';
}
set value(val) {
this._value = val;
const textarea = this.shadowRoot?.querySelector('.textarea-field');
if (textarea) {
textarea.value = val;
}
}
}
// Register the custom elements
customElements.define('custom-input', CustomInput);
customElements.define('custom-textarea', CustomTextarea);
// Theme change observer
const themeObserver = new MutationObserver(() => {
document.querySelectorAll('custom-input, custom-textarea').forEach(el => {
if (el.updateTheme) el.updateTheme();
});
});
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class']
});