diff --git a/package.json b/package.json index e72cb21..f7a59af 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pages", - "version": "0.0.4", + "version": "0.0.5", "description": "Doctorina Pages - Multiple lightweight HTML pages", "type": "module", "scripts": { diff --git a/pages/chat/main.ts b/pages/chat/main.ts index 9f1558d..e5c2193 100644 --- a/pages/chat/main.ts +++ b/pages/chat/main.ts @@ -24,10 +24,8 @@ const i18n = { attrDefault: 'Default', attrTargetUrl: 'App URL to redirect to', attrTargetUrlDefault: 'app.doctorina.com', - attrPlaceholder: 'Static placeholder text', - attrPlaceholderDefault: 'None', - attrPhrases: 'JSON array of phrases for typing animation', - attrPhrasesDefault: 'None', + attrChildren: 'Child <p> elements: 1 = static placeholder, 2+ = typing animation', + attrChildrenDefault: 'None', attrParams: 'JSON object of extra query parameters (e.g. UTM tags)', attrParamsDefault: 'None', examples: 'Examples', @@ -39,6 +37,9 @@ const i18n = { exPlaceholderDesc: 'Simple static placeholder text:', exParamsTitle: 'With UTM Parameters', exParamsDesc: 'Pass custom query parameters (e.g. UTM tags) to the target URL:', + demoPhrase1: 'What symptoms do you have?', + demoPhrase2: 'Ask about medications', + demoPhrase3: 'Describe how you feel', multiWidget: 'Multiple Widgets', multiWidgetDesc: 'Use the class doctorina-chat instead of id to place multiple widgets on the same page. Each can have its own configuration.', programmatic: 'Programmatic API', @@ -60,10 +61,8 @@ const i18n = { attrDefault: 'Predeterminado', attrTargetUrl: 'URL de la app para redirigir', attrTargetUrlDefault: 'app.doctorina.com', - attrPlaceholder: 'Texto estático del placeholder', - attrPlaceholderDefault: 'Ninguno', - attrPhrases: 'Array JSON de frases para la animación de escritura', - attrPhrasesDefault: 'Ninguno', + attrChildren: 'Elementos hijos <p>: 1 = placeholder estático, 2+ = animación de escritura', + attrChildrenDefault: 'Ninguno', attrParams: 'Objeto JSON con parámetros extra (ej. etiquetas UTM)', attrParamsDefault: 'Ninguno', examples: 'Ejemplos', @@ -75,6 +74,9 @@ const i18n = { exPlaceholderDesc: 'Texto estático simple en el campo de entrada:', exParamsTitle: 'Con parámetros UTM', exParamsDesc: 'Pasa parámetros personalizados (ej. etiquetas UTM) a la URL de destino:', + demoPhrase1: '¿Qué síntomas tienes?', + demoPhrase2: 'Pregunta sobre medicamentos', + demoPhrase3: 'Describe cómo te sientes', multiWidget: 'Múltiples widgets', multiWidgetDesc: 'Usa la clase doctorina-chat en lugar de id para colocar múltiples widgets en la misma página. Cada uno puede tener su propia configuración.', programmatic: 'API programática', @@ -96,10 +98,8 @@ const i18n = { attrDefault: 'По умолчанию', attrTargetUrl: 'URL приложения для редиректа', attrTargetUrlDefault: 'app.doctorina.com', - attrPlaceholder: 'Статический текст placeholder', - attrPlaceholderDefault: 'Нет', - attrPhrases: 'JSON-массив фраз для анимации печатания', - attrPhrasesDefault: 'Нет', + attrChildren: 'Дочерние элементы <p>: 1 = статический placeholder, 2+ = анимация печатания', + attrChildrenDefault: 'Нет', attrParams: 'JSON-объект с дополнительными query-параметрами (напр. UTM-метки)', attrParamsDefault: 'Нет', examples: 'Примеры', @@ -111,6 +111,9 @@ const i18n = { exPlaceholderDesc: 'Простой статический текст в поле ввода:', exParamsTitle: 'С UTM-параметрами', exParamsDesc: 'Передача произвольных query-параметров (напр. UTM-меток) в целевой URL:', + demoPhrase1: 'Какие у вас симптомы?', + demoPhrase2: 'Спросите о лекарствах', + demoPhrase3: 'Опишите как вы себя чувствуете', multiWidget: 'Несколько виджетов', multiWidgetDesc: 'Используйте класс doctorina-chat вместо id, чтобы разместить несколько виджетов на одной странице. Каждый может иметь свою конфигурацию.', programmatic: 'Программный API', @@ -131,13 +134,15 @@ const SNIPPET_BASIC = `
`; -const SNIPPET_PHRASES = `
+const SNIPPET_PHRASES = `
+

What symptoms do you have?

+

Ask about medications

+

Get a health consultation

`; -const SNIPPET_PLACEHOLDER = `
+const SNIPPET_PLACEHOLDER = `
+

Type your health question...

`; @@ -172,8 +177,10 @@ function render(lang: Lang): void {

${t.demoDesc}

+ data-target-url="https://doctorina-development.web.app"> +

${t.demoPhrase1}

+

${t.demoPhrase2}

+

${t.demoPhrase3}

@@ -197,8 +204,7 @@ function render(lang: Lang): void { data-target-url${t.attrTargetUrl}${t.attrTargetUrlDefault} - data-placeholder${t.attrPlaceholder}${t.attrPlaceholderDefault} - data-phrases${t.attrPhrases}${t.attrPhrasesDefault} + <p> children${t.attrChildren}${t.attrChildrenDefault} data-params${t.attrParams}${t.attrParamsDefault} diff --git a/src/widget/config.ts b/src/widget/config.ts index 129f14e..9cb955d 100644 --- a/src/widget/config.ts +++ b/src/widget/config.ts @@ -14,21 +14,16 @@ export function parseConfig(container: HTMLElement): WidgetConfig { targetUrl: targetUrl.replace(/\/+$/, ''), }; - const placeholder = container.getAttribute('data-placeholder'); - if (placeholder) { - config.placeholder = placeholder; - } + // Child elements = phrases for placeholder animation (translation-plugin friendly) + // 1 element → static placeholder, 2+ elements → animated cycling + const childTexts = Array.from(container.children) + .map((el) => el.textContent?.trim() ?? '') + .filter((text) => text.length > 0); - const phrasesAttr = container.getAttribute('data-phrases'); - if (phrasesAttr) { - try { - const parsed = JSON.parse(phrasesAttr); - if (Array.isArray(parsed) && parsed.length > 0) { - config.phrases = parsed.filter((p): p is string => typeof p === 'string' && p.trim() !== ''); - } - } catch { - console.warn('[DoctorinaChat] Invalid data-phrases JSON:', phrasesAttr); - } + if (childTexts.length === 1) { + config.placeholder = childTexts[0]; + } else if (childTexts.length > 1) { + config.phrases = childTexts; } const paramsAttr = container.getAttribute('data-params'); diff --git a/src/widget/doctorina-chat.ts b/src/widget/doctorina-chat.ts index 09b75bb..4921f65 100644 --- a/src/widget/doctorina-chat.ts +++ b/src/widget/doctorina-chat.ts @@ -12,6 +12,7 @@ export function init(): void { container.dataset.dchatInited = '1'; const config = parseConfig(container); + container.innerHTML = ''; injectStyles(); createWidget(container, config); diff --git a/src/widget/placeholder-animation.ts b/src/widget/placeholder-animation.ts index 06b9d93..c49eb2f 100644 --- a/src/widget/placeholder-animation.ts +++ b/src/widget/placeholder-animation.ts @@ -10,6 +10,12 @@ export function startPlaceholderAnimation( ): () => void { if (phrases.length === 0) return () => {}; + // Respect user motion preferences — show first phrase as static placeholder + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + textarea.placeholder = phrases[0]; + return () => {}; + } + let phraseIndex = 0; let charIndex = 0; let deleting = false; diff --git a/src/widget/styles.ts b/src/widget/styles.ts index 02794ca..6cdf272 100644 --- a/src/widget/styles.ts +++ b/src/widget/styles.ts @@ -37,7 +37,6 @@ const CSS = ` .dchat-textarea:focus { outline: none; - box-shadow: none; } .dchat-textarea::placeholder { @@ -75,6 +74,10 @@ const CSS = ` position: relative; } +.dchat-btn:focus-visible { + box-shadow: 0 0 0 2px rgba(37, 211, 102, 0.35); +} + .dchat-btn:active:not(:disabled) { transform: scale(0.94); } diff --git a/src/widget/ui.ts b/src/widget/ui.ts index 6c73398..d663ed2 100644 --- a/src/widget/ui.ts +++ b/src/widget/ui.ts @@ -5,17 +5,16 @@ import { startPlaceholderAnimation } from './placeholder-animation'; const SEND_ICON_SVG = ``; export function createWidget(container: HTMLElement, config: WidgetConfig): void { - // Build DOM const box = document.createElement('div'); box.className = 'dchat-box'; box.setAttribute('role', 'form'); - box.setAttribute('aria-label', 'Chat message'); + box.setAttribute('aria-label', container.getAttribute('data-aria-label') || 'Chat message'); const textarea = document.createElement('textarea'); textarea.className = 'dchat-textarea'; textarea.rows = 1; textarea.setAttribute('autocomplete', 'off'); - textarea.setAttribute('aria-label', 'Chat message'); + textarea.setAttribute('aria-label', container.getAttribute('data-aria-input') || 'Message'); textarea.maxLength = 4096; if (config.placeholder) { @@ -25,8 +24,7 @@ export function createWidget(container: HTMLElement, config: WidgetConfig): void const btn = document.createElement('button'); btn.className = 'dchat-btn'; btn.type = 'button'; - btn.setAttribute('aria-label', 'Send message'); - btn.title = 'Send message'; + btn.setAttribute('aria-label', container.getAttribute('data-aria-send') || 'Send'); btn.disabled = true; btn.setAttribute('aria-disabled', 'true'); btn.innerHTML = SEND_ICON_SVG; @@ -63,12 +61,13 @@ export function createWidget(container: HTMLElement, config: WidgetConfig): void btn.addEventListener('click', submit); - textarea.addEventListener('keydown', (e) => { + function onKeydown(e: KeyboardEvent): void { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); submit(); } - }); + } + textarea.addEventListener('keydown', onKeydown); // Placeholder animation let destroyAnimation: (() => void) | undefined; @@ -76,13 +75,15 @@ export function createWidget(container: HTMLElement, config: WidgetConfig): void destroyAnimation = startPlaceholderAnimation(textarea, config.phrases); } - // Expose cleanup for SPA / dynamic removal + // Cleanup for SPA / dynamic removal container.addEventListener( 'dchat:destroy', () => { destroyAnimation?.(); textarea.removeEventListener('input', autoResize); textarea.removeEventListener('input', updateButton); + textarea.removeEventListener('keydown', onKeydown); + btn.removeEventListener('click', submit); }, { once: true }, );