From 16e014afd35a0cbaead0b5fa351b52c367881686 Mon Sep 17 00:00:00 2001 From: ale Date: Sun, 5 Oct 2025 03:55:22 +0200 Subject: [PATCH 1/3] accessibility Signed-off-by: ale --- docs/source/accessibility.rst | 343 +++++++++++ src/index.js | 2 + src/plugins/accessibility/README.md | 244 ++++++++ src/plugins/accessibility/index.js | 222 +++++++ .../accessibility/keyboard-shortcuts.js | 526 ++++++++++++++++ src/plugins/accessibility/modal.js | 120 ++++ src/plugins/accessibility/settings-panel.js | 301 ++++++++++ .../styles/accessibility-settings.scss | 211 +++++++ .../accessibility/styles/accessibility.scss | 383 ++++++++++++ src/plugins/chatview/bottom-panel.js | 70 ++- src/plugins/chatview/index.js | 3 +- .../chatview/templates/bottom-panel.js | 15 + src/plugins/chatview/templates/chat.js | 23 +- .../chatview/templates/message-form.js | 2 + .../profile/modals/templates/user-settings.js | 34 +- src/plugins/voice-messages/IMPLEMENTATION.md | 285 +++++++++ .../voice-messages/INTEGRATION_EXAMPLE.js | 480 +++++++++++++++ src/plugins/voice-messages/README.md | 459 ++++++++++++++ src/plugins/voice-messages/audio-player.js | 443 ++++++++++++++ src/plugins/voice-messages/audio-recorder.js | 562 ++++++++++++++++++ src/plugins/voice-messages/index.js | 295 +++++++++ .../voice-messages/styles/audio-player.scss | 374 ++++++++++++ .../voice-messages/styles/audio-recorder.scss | 262 ++++++++ src/shared/chat/templates/message.js | 53 +- src/shared/chat/toolbar.js | 56 ++ .../components/screen-reader-announcer.js | 78 +++ src/shared/components/templates/icons.js | 12 + src/shared/constants.js | 4 +- src/utils/accessibility.js | 417 +++++++++++++ 29 files changed, 6259 insertions(+), 20 deletions(-) create mode 100644 docs/source/accessibility.rst create mode 100644 src/plugins/accessibility/README.md create mode 100644 src/plugins/accessibility/index.js create mode 100644 src/plugins/accessibility/keyboard-shortcuts.js create mode 100644 src/plugins/accessibility/modal.js create mode 100644 src/plugins/accessibility/settings-panel.js create mode 100644 src/plugins/accessibility/styles/accessibility-settings.scss create mode 100644 src/plugins/accessibility/styles/accessibility.scss create mode 100644 src/plugins/voice-messages/IMPLEMENTATION.md create mode 100644 src/plugins/voice-messages/INTEGRATION_EXAMPLE.js create mode 100644 src/plugins/voice-messages/README.md create mode 100644 src/plugins/voice-messages/audio-player.js create mode 100644 src/plugins/voice-messages/audio-recorder.js create mode 100644 src/plugins/voice-messages/index.js create mode 100644 src/plugins/voice-messages/styles/audio-player.scss create mode 100644 src/plugins/voice-messages/styles/audio-recorder.scss create mode 100644 src/shared/components/screen-reader-announcer.js create mode 100644 src/utils/accessibility.js diff --git a/docs/source/accessibility.rst b/docs/source/accessibility.rst new file mode 100644 index 0000000000..9b61a9e7ae --- /dev/null +++ b/docs/source/accessibility.rst @@ -0,0 +1,343 @@ +.. _accessibility: + +Accesibilidad +============= + +Converse.js está comprometido con proporcionar una experiencia accesible para todos los usuarios, +incluyendo aquellos que utilizan tecnologías de asistencia como lectores de pantalla o navegación +exclusiva por teclado. + +.. contents:: Tabla de contenidos + :depth: 3 + :local: + +Características de accesibilidad +--------------------------------- + +Navegación por teclado +~~~~~~~~~~~~~~~~~~~~~~~ + +Converse.js ofrece soporte completo para navegación por teclado, permitiendo a los usuarios +interactuar con todas las funciones sin necesidad de un ratón. + +Atajos de teclado globales +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Los siguientes atajos de teclado están disponibles en toda la aplicación: + +.. list-table:: + :header-rows: 1 + :widths: 30 70 + + * - Atajo + - Descripción + * - ``Alt+Shift+H`` + - Mostrar/ocultar ayuda de atajos de teclado + * - ``Alt+Shift+C`` + - Enfocar el área de composición de mensajes + * - ``Alt+Shift+L`` + - Enfocar la lista de chats + * - ``Alt+Shift+M`` + - Ir al último mensaje del chat actual + * - ``Alt+Shift+N`` + - Ir al siguiente chat con mensajes no leídos + * - ``Alt+Shift+P`` + - Ir al chat anterior en la lista + * - ``Alt+Shift+S`` + - Enfocar el campo de búsqueda de contactos + * - ``Escape`` + - Cerrar modal o diálogo abierto + +Atajos en el compositor de mensajes +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Cuando el área de composición de mensajes está enfocada: + +.. list-table:: + :header-rows: 1 + :widths: 30 70 + + * - Atajo + - Descripción + * - ``Ctrl+Enter`` + - Enviar el mensaje actual + * - ``Alt+Shift+E`` + - Abrir selector de emojis + * - ``Alt+Shift+F`` + - Abrir selector de archivos para adjuntar + +Atajos de navegación en mensajes +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Para navegar entre mensajes: + +.. list-table:: + :header-rows: 1 + :widths: 30 70 + + * - Atajo + - Descripción + * - ``Alt+↑`` + - Ir al mensaje anterior + * - ``Alt+↓`` + - Ir al siguiente mensaje + * - ``Alt+Shift+R`` + - Responder al mensaje enfocado + +Lectores de pantalla +~~~~~~~~~~~~~~~~~~~~~ + +Converse.js incluye soporte completo para lectores de pantalla mediante: + +* **Etiquetas ARIA apropiadas**: Todos los elementos interactivos incluyen etiquetas descriptivas +* **Roles ARIA semánticos**: Los componentes utilizan roles apropiados (region, log, toolbar, etc.) +* **Anuncios en vivo**: Los eventos importantes se anuncian automáticamente +* **Navegación lógica**: El orden de tabulación sigue un flujo lógico y predecible + +Anuncios automáticos +^^^^^^^^^^^^^^^^^^^^ + +El lector de pantalla anunciará automáticamente: + +* Nuevos mensajes entrantes (con nombre del remitente) +* Cambios de estado de contactos +* Unión/salida de usuarios en salas de chat +* Errores y notificaciones importantes +* Apertura y cierre de diálogos + +Configuración +------------- + +Opciones de accesibilidad +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Puede configurar el comportamiento de accesibilidad mediante las siguientes opciones: + +``enable_accessibility`` +^^^^^^^^^^^^^^^^^^^^^^^^ + +* **Tipo**: Boolean +* **Predeterminado**: ``true`` +* **Descripción**: Habilita o deshabilita todas las funciones de accesibilidad mejoradas + +.. code-block:: javascript + + converse.initialize({ + enable_accessibility: true + }); + +``enable_keyboard_shortcuts`` +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +* **Tipo**: Boolean +* **Predeterminado**: ``true`` +* **Descripción**: Habilita o deshabilita los atajos de teclado + +.. code-block:: javascript + + converse.initialize({ + enable_keyboard_shortcuts: true + }); + +``enable_screen_reader_announcements`` +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +* **Tipo**: Boolean +* **Predeterminado**: ``true`` +* **Descripción**: Habilita o deshabilita los anuncios para lectores de pantalla + +.. code-block:: javascript + + converse.initialize({ + enable_screen_reader_announcements: true + }); + +``announce_new_messages`` +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +* **Tipo**: Boolean +* **Predeterminado**: ``true`` +* **Descripción**: Anuncia automáticamente los nuevos mensajes entrantes + +.. code-block:: javascript + + converse.initialize({ + announce_new_messages: true + }); + +``announce_status_changes`` +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +* **Tipo**: Boolean +* **Predeterminado**: ``true`` +* **Descripción**: Anuncia los cambios de estado de los contactos + +.. code-block:: javascript + + converse.initialize({ + announce_status_changes: true + }); + +``high_contrast_mode`` +^^^^^^^^^^^^^^^^^^^^^^^ + +* **Tipo**: Boolean | 'auto' +* **Predeterminado**: ``'auto'`` +* **Descripción**: Activa el modo de alto contraste. 'auto' detecta la preferencia del sistema + +.. code-block:: javascript + + converse.initialize({ + high_contrast_mode: 'auto' // o true/false + }); + +API de accesibilidad +-------------------- + +Converse.js expone una API para que los desarrolladores puedan integrar funciones de accesibilidad +en plugins personalizados. + +Anunciar mensajes +~~~~~~~~~~~~~~~~~ + +Para anunciar un mensaje a los lectores de pantalla: + +.. code-block:: javascript + + converse.api.accessibility.announce( + 'Mensaje a anunciar', + 'polite' // o 'assertive' para mayor prioridad + ); + +Gestión de foco +~~~~~~~~~~~~~~~ + +Mover el foco a un elemento específico: + +.. code-block:: javascript + + const element = document.querySelector('.chat-textarea'); + converse.api.accessibility.moveFocus(element, { + preventScroll: false, + announce: 'Área de texto enfocada' + }); + +Obtener elementos enfocables +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: javascript + + const container = document.querySelector('.chat-content'); + const focusableElements = converse.api.accessibility.getFocusableElements(container); + +Trap de foco +~~~~~~~~~~~~ + +Útil para modales y diálogos: + +.. code-block:: javascript + + const modal = document.querySelector('.modal'); + const releaseTrap = converse.api.accessibility.trapFocus(modal); + + // Cuando se cierre el modal + releaseTrap(); + +Registrar atajos personalizados +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: javascript + + converse.api.accessibility.registerShortcuts({ + 'Ctrl+Alt+X': (event) => { + // Manejar el atajo + console.log('Atajo personalizado activado'); + } + }); + +Mejores prácticas +----------------- + +Para desarrolladores +~~~~~~~~~~~~~~~~~~~~ + +Si está desarrollando plugins o personalizaciones para Converse.js, siga estas mejores prácticas: + +1. **Siempre incluya etiquetas ARIA** + + .. code-block:: html + + + +2. **Use roles semánticos apropiados** + + .. code-block:: html + +
+ +
+ +3. **Asegure el orden de tabulación lógico** + + Use ``tabindex`` apropiadamente: + + * ``tabindex="0"`` para elementos que deben estar en el flujo natural + * ``tabindex="-1"`` para elementos que deben ser enfocables programáticamente + * Evite valores positivos de ``tabindex`` + +4. **Proporcione alternativas textuales** + + .. code-block:: html + + emoji sonriente + + +5. **Anuncie cambios dinámicos** + + .. code-block:: javascript + + converse.api.accessibility.announce('Se agregó un nuevo contacto'); + +6. **Pruebe con lectores de pantalla** + + * NVDA (Windows) - Gratuito + * JAWS (Windows) - Comercial + * VoiceOver (macOS/iOS) - Integrado + * TalkBack (Android) - Integrado + * Orca (Linux) - Gratuito + +Para usuarios +~~~~~~~~~~~~~ + +Consejos para una mejor experiencia: + +1. **Aprenda los atajos de teclado**: Presione ``Alt+Shift+H`` para ver todos los atajos disponibles + +2. **Configure su lector de pantalla**: Asegúrese de que su lector de pantalla esté configurado para anunciar regiones ARIA live + +3. **Use el modo de navegación apropiado**: En navegadores, use el modo de formulario/foco cuando interactúe con los campos de chat + +4. **Ajuste la configuración**: Desactive los anuncios que encuentre molestos mediante las opciones de configuración + +Recursos adicionales +-------------------- + +* `Web Content Accessibility Guidelines (WCAG) `_ +* `ARIA Authoring Practices Guide `_ +* `WebAIM - Recursos de accesibilidad web `_ + +Reportar problemas +------------------ + +Si encuentra problemas de accesibilidad o tiene sugerencias para mejorar, por favor: + +1. Reporte el problema en nuestro `rastreador de issues en GitHub `_ +2. Etiquete el issue con ``accessibility`` +3. Incluya: + + * Descripción detallada del problema + * Navegador y versión + * Tecnología de asistencia utilizada (si aplica) + * Pasos para reproducir + +Trabajamos continuamente para mejorar la accesibilidad de Converse.js y agradecemos sus comentarios. diff --git a/src/index.js b/src/index.js index 94971989d0..79925c191f 100644 --- a/src/index.js +++ b/src/index.js @@ -21,6 +21,7 @@ Object.assign(_converse.env, { i18n }); * ------------------------ * Any of the following plugin imports may be removed if the plugin is not needed */ +import "./plugins/accessibility/index.js"; // Accessibility features for screen readers and keyboard navigation import "./plugins/modal/index.js"; import "./plugins/adhoc-views/index.js"; // Views for XEP-0050 Ad-Hoc commands import "./plugins/bookmark-views/index.js"; // Views for XEP-0048 Bookmarks @@ -42,6 +43,7 @@ import "./plugins/rosterview/index.js"; import "./plugins/singleton/index.js"; import "./plugins/dragresize/index.js"; // Allows chat boxes to be resized by dragging them import "./plugins/fullscreen/index.js"; +import "./plugins/voice-messages/index.js"; // Voice message recording and playback with accessibility /* END: Removable components */ _converse.exports.CustomElement = CustomElement; diff --git a/src/plugins/accessibility/README.md b/src/plugins/accessibility/README.md new file mode 100644 index 0000000000..b09981dca1 --- /dev/null +++ b/src/plugins/accessibility/README.md @@ -0,0 +1,244 @@ +# Plugin de Accesibilidad para Converse.js + +## Descripción + +Este plugin mejora significativamente la accesibilidad de Converse.js para usuarios con discapacidades visuales y motoras, incluyendo: + +- **Soporte completo para lectores de pantalla** (NVDA, JAWS, VoiceOver, TalkBack, Orca) +- **Navegación completa por teclado** con atajos personalizables +- **Modo de alto contraste** automático o manual +- **Anuncios ARIA en vivo** para eventos importantes +- **Gestión de foco mejorada** para modales y diálogos + +## Características principales + +### 🎹 Atajos de teclado + +El plugin proporciona atajos de teclado intuitivos para todas las funciones principales: + +#### Globales +- `Alt+Shift+H` - Mostrar ayuda de atajos +- `Alt+Shift+C` - Enfocar compositor de mensajes +- `Alt+Shift+L` - Enfocar lista de chats +- `Alt+Shift+M` - Ir al último mensaje +- `Alt+Shift+N` - Siguiente chat no leído +- `Alt+Shift+S` - Buscar contactos +- `Escape` - Cerrar modal actual + +#### En el compositor +- `Ctrl+Enter` - Enviar mensaje +- `Alt+Shift+E` - Selector de emoji +- `Alt+Shift+F` - Adjuntar archivo + +#### En mensajes +- `Alt+↑/↓` - Navegar entre mensajes +- `Alt+Shift+R` - Responder mensaje + +### 📢 Anuncios para lectores de pantalla + +El plugin anuncia automáticamente: + +- Nuevos mensajes entrantes con nombre del remitente +- Cambios de estado de contactos (online, away, etc.) +- Usuarios que se unen/salen de salas +- Errores y notificaciones importantes +- Apertura/cierre de diálogos + +### ♿ Mejoras ARIA + +Todos los componentes incluyen: + +- Roles ARIA semánticos apropiados +- Etiquetas descriptivas (aria-label) +- Regiones live para contenido dinámico +- Estados y propiedades ARIA correctos +- Orden de tabulación lógico + +### 🎨 Modo de alto contraste + +- Detección automática de preferencias del sistema +- Activación manual disponible +- Mejora de contraste en todos los elementos +- Bordes y contornos más visibles +- Estados de foco mejorados + +## Instalación + +El plugin está incluido por defecto en Converse.js. Para habilitarlo: + +```javascript +converse.initialize({ + enable_accessibility: true, + enable_keyboard_shortcuts: true, + enable_screen_reader_announcements: true, + announce_new_messages: true, + announce_status_changes: true, + high_contrast_mode: 'auto' +}); +``` + +## Configuración + +### Opciones disponibles + +#### `enable_accessibility` +- **Tipo:** `boolean` +- **Default:** `true` +- **Descripción:** Habilita todas las funciones de accesibilidad + +#### `enable_keyboard_shortcuts` +- **Tipo:** `boolean` +- **Default:** `true` +- **Descripción:** Habilita los atajos de teclado + +#### `enable_screen_reader_announcements` +- **Tipo:** `boolean` +- **Default:** `true` +- **Descripción:** Habilita anuncios para lectores de pantalla + +#### `announce_new_messages` +- **Tipo:** `boolean` +- **Default:** `true` +- **Descripción:** Anuncia nuevos mensajes automáticamente + +#### `announce_status_changes` +- **Tipo:** `boolean` +- **Default:** `true` +- **Descripción:** Anuncia cambios de estado de contactos + +#### `high_contrast_mode` +- **Tipo:** `boolean | 'auto'` +- **Default:** `'auto'` +- **Descripción:** Activa modo de alto contraste + +## API para desarrolladores + +### Anunciar mensajes + +```javascript +converse.api.accessibility.announce( + 'Mensaje importante', + 'assertive' // o 'polite' +); +``` + +### Gestión de foco + +```javascript +const element = document.querySelector('.chat-textarea'); +converse.api.accessibility.moveFocus(element, { + preventScroll: false, + announce: 'Campo de texto enfocado' +}); +``` + +### Trap de foco (para modales) + +```javascript +const modal = document.querySelector('.modal'); +const release = converse.api.accessibility.trapFocus(modal); + +// Cuando se cierra el modal +release(); +``` + +### Registrar atajos personalizados + +```javascript +converse.api.accessibility.registerShortcuts({ + 'Ctrl+Alt+X': (event) => { + console.log('Atajo personalizado'); + } +}); +``` + +### Obtener elementos enfocables + +```javascript +const container = document.querySelector('.chat-content'); +const focusable = converse.api.accessibility.getFocusableElements(container); +``` + +## Estructura de archivos + +``` +src/plugins/accessibility/ +├── index.js # Plugin principal +├── keyboard-shortcuts.js # Sistema de atajos +├── modal.js # Modal de ayuda +└── styles/ + └── accessibility.scss # Estilos de accesibilidad + +src/utils/ +└── accessibility.js # Utilidades compartidas + +src/shared/components/ +└── screen-reader-announcer.js # Componente de anuncios +``` + +## Pruebas + +### Lectores de pantalla recomendados + +- **Windows:** NVDA (gratis), JAWS (comercial) +- **macOS:** VoiceOver (incluido) +- **Linux:** Orca (gratis) +- **Android:** TalkBack (incluido) +- **iOS:** VoiceOver (incluido) + +### Lista de verificación + +- [ ] Navegación completa por teclado +- [ ] Todos los elementos interactivos son enfocables +- [ ] Orden de tabulación lógico +- [ ] Etiquetas ARIA apropiadas +- [ ] Anuncios funcionan correctamente +- [ ] Contraste de colores adecuado (WCAG AA) +- [ ] Estados de foco visibles +- [ ] Funciona sin ratón + +## Cumplimiento de estándares + +Este plugin sigue: + +- **WCAG 2.1 Level AA** - Web Content Accessibility Guidelines +- **ARIA 1.2** - Accessible Rich Internet Applications +- **Section 508** - Estándares de accesibilidad de EE.UU. +- **EN 301 549** - Estándares europeos de accesibilidad + +## Contribuir + +Para mejorar la accesibilidad: + +1. Pruebe con tecnologías de asistencia reales +2. Siga las guías ARIA Authoring Practices +3. Use validadores de accesibilidad (axe, WAVE) +4. Documente cambios en accessibility.rst +5. Agregue pruebas automatizadas cuando sea posible + +## Recursos + +- [WCAG 2.1 Quick Reference](https://www.w3.org/WAI/WCAG21/quickref/) +- [ARIA Authoring Practices Guide](https://www.w3.org/WAI/ARIA/apg/) +- [WebAIM](https://webaim.org/) +- [The A11Y Project](https://www.a11yproject.com/) + +## Licencia + +MPL-2.0 (igual que Converse.js) + +## Soporte + +Para reportar problemas de accesibilidad: + +1. Abra un issue en GitHub +2. Etiquételo con `accessibility` +3. Incluya: + - Navegador y versión + - Tecnología de asistencia usada + - Pasos para reproducir + - Comportamiento esperado vs actual + +--- + +**Nota:** La accesibilidad es un proceso continuo. Agradecemos cualquier retroalimentación para mejorar la experiencia de todos los usuarios. diff --git a/src/plugins/accessibility/index.js b/src/plugins/accessibility/index.js new file mode 100644 index 0000000000..13649c53e0 --- /dev/null +++ b/src/plugins/accessibility/index.js @@ -0,0 +1,222 @@ +/** + * @module accessibility + * @description Plugin de accesibilidad para Converse.js + * Mejora la experiencia para usuarios de lectores de pantalla y teclado + */ + +import { _converse, api, converse } from '@converse/headless'; +import { __ } from 'i18n'; +import { initAccessibilityAPI } from '../../utils/accessibility.js'; +import { + announceToScreenReader, + announceNewMessage, + announceStatusChange, + initLiveRegion +} from '../../utils/accessibility.js'; +import { initKeyboardShortcuts } from './keyboard-shortcuts.js'; +import './modal.js'; +import './settings-panel.js'; +import '../../shared/components/screen-reader-announcer.js'; + +converse.plugins.add('converse-accessibility', { + + dependencies: ['converse-chatboxes', 'converse-roster', 'converse-muc'], + + initialize() { + // Configuración del plugin + api.settings.extend({ + /** + * Habilita funciones de accesibilidad mejoradas + * @type {boolean} + */ + enable_accessibility: true, + + /** + * Habilita atajos de teclado + * @type {boolean} + */ + enable_keyboard_shortcuts: true, + + /** + * Habilita anuncios para lectores de pantalla + * @type {boolean} + */ + enable_screen_reader_announcements: true, + + /** + * Anunciar nuevos mensajes automáticamente + * @type {boolean} + */ + announce_new_messages: true, + + /** + * Anunciar cambios de estado de contactos + * @type {boolean} + */ + announce_status_changes: true, + + /** + * Modo de alto contraste + * @type {boolean|'auto'} + */ + high_contrast_mode: 'auto' + }); + + // Inicializar solo si está habilitado + api.listen.on('connected', () => { + if (api.settings.get('enable_accessibility')) { + initializeAccessibility(); + } + }); + + api.listen.on('reconnected', () => { + if (api.settings.get('enable_accessibility')) { + initializeAccessibility(); + } + }); + } +}); + +/** + * Inicializa las funciones de accesibilidad + */ +function initializeAccessibility() { + // Inicializar API de accesibilidad + initAccessibilityAPI(); + + // Inicializar región live + initLiveRegion(); + + // Inicializar atajos de teclado + if (api.settings.get('enable_keyboard_shortcuts')) { + initKeyboardShortcuts(); + } + + // Configurar listeners para anuncios + if (api.settings.get('enable_screen_reader_announcements')) { + setupScreenReaderAnnouncements(); + } + + // Aplicar mejoras de alto contraste si es necesario + applyHighContrastMode(); + + // Anunciar que la aplicación está lista + announceToScreenReader( + __('Converse.js cargado. Presione Alt+Shift+H para ver los atajos de teclado disponibles.'), + 'polite', + 2000 + ); +} + +/** + * Configura los anuncios para lectores de pantalla + */ +function setupScreenReaderAnnouncements() { + const announce_new_messages = api.settings.get('announce_new_messages'); + const announce_status_changes = api.settings.get('announce_status_changes'); + + // Anunciar nuevos mensajes + if (announce_new_messages) { + api.listen.on('message', (data) => { + const { chatbox, stanza } = data; + const is_current = _converse.state.chatboxviews.get(chatbox.get('jid'))?.model === chatbox; + + // Solo anunciar mensajes entrantes + if (stanza.getAttribute('from') !== _converse.session.get('jid')) { + announceNewMessage({ + sender_name: chatbox.getDisplayName(), + body: stanza.querySelector('body')?.textContent, + type: chatbox.get('type') + }, is_current); + } + }); + } + + // Anunciar cambios de estado + if (announce_status_changes) { + api.listen.on('statusChanged', (status) => { + const contact = _converse.state.roster?.get(status.from); + if (contact) { + announceStatusChange(status.show, contact.getDisplayName()); + } + }); + } + + // Anunciar cuando se une/sale alguien de una sala + api.listen.on('chatRoomPresence', (data) => { + const { presence, room } = data; + const from = presence.getAttribute('from'); + const nick = presence.querySelector('nick')?.textContent; + const type = presence.getAttribute('type'); + + if (type === 'unavailable') { + announceToScreenReader( + __('%1$s ha salido de la sala', nick || from) + ); + } else { + announceToScreenReader( + __('%1$s se ha unido a la sala', nick || from) + ); + } + }); + + // Anunciar errores + api.listen.on('chatBoxClosed', (chatbox) => { + announceToScreenReader( + __('Chat con %1$s cerrado', chatbox.getDisplayName()) + ); + }); +} + +/** + * Aplica el modo de alto contraste si es necesario + */ +function applyHighContrastMode() { + const mode = api.settings.get('high_contrast_mode'); + + if (mode === 'auto') { + // Detectar si el sistema está en modo de alto contraste + const mediaQuery = window.matchMedia('(prefers-contrast: high)'); + + if (mediaQuery.matches) { + document.body.classList.add('converse-high-contrast'); + } + + // Escuchar cambios + mediaQuery.addEventListener('change', (e) => { + document.body.classList.toggle('converse-high-contrast', e.matches); + }); + } else if (mode === true) { + document.body.classList.add('converse-high-contrast'); + } +} + +/** + * Modal de ayuda de atajos de teclado + */ +export function showKeyboardShortcutsModal() { + const shortcuts = [ + { key: 'Alt+Shift+H', description: __('Mostrar/ocultar esta ayuda'), context: __('Global') }, + { key: 'Alt+Shift+C', description: __('Enfocar área de composición'), context: __('Global') }, + { key: 'Alt+Shift+L', description: __('Enfocar lista de chats'), context: __('Global') }, + { key: 'Alt+Shift+M', description: __('Ir al último mensaje'), context: __('Global') }, + { key: 'Alt+Shift+N', description: __('Siguiente chat no leído'), context: __('Global') }, + { key: 'Alt+Shift+S', description: __('Buscar contactos'), context: __('Global') }, + { key: 'Escape', description: __('Cerrar modal'), context: __('Global') }, + { key: 'Ctrl+Enter', description: __('Enviar mensaje'), context: __('Compositor') }, + { key: 'Alt+Shift+E', description: __('Insertar emoji'), context: __('Compositor') }, + { key: 'Alt+Shift+F', description: __('Adjuntar archivo'), context: __('Compositor') }, + { key: 'Alt+↑', description: __('Mensaje anterior'), context: __('Mensajes') }, + { key: 'Alt+↓', description: __('Mensaje siguiente'), context: __('Mensajes') }, + { key: 'Alt+Shift+R', description: __('Responder mensaje'), context: __('Mensajes') } + ]; + + api.modal.show('converse-keyboard-shortcuts-modal', { shortcuts }); +} + +export default { + initializeAccessibility, + setupScreenReaderAnnouncements, + applyHighContrastMode, + showKeyboardShortcutsModal +}; diff --git a/src/plugins/accessibility/keyboard-shortcuts.js b/src/plugins/accessibility/keyboard-shortcuts.js new file mode 100644 index 0000000000..b80652f45e --- /dev/null +++ b/src/plugins/accessibility/keyboard-shortcuts.js @@ -0,0 +1,526 @@ +/** + * @module accessibility/keyboard-shortcuts + * @description Sistema de atajos de teclado para mejorar la navegación accesible + */ + +import { api, _converse, constants } from '@converse/headless'; +import { __ } from 'i18n'; +import { announceToScreenReader, moveFocusTo } from '../../utils/accessibility.js'; + +const { KEYCODES } = constants; + +/** + * @typedef {Object} KeyboardShortcut + * @property {string} key - Combinación de teclas + * @property {string} description - Descripción del atajo + * @property {Function} handler - Manejador del atajo + * @property {string} [context] - Contexto donde aplica el atajo + */ + +/** + * Atajos de teclado globales del sistema + * @type {Map} + */ +const globalShortcuts = new Map(); + +/** + * Atajos de teclado contextuales + * @type {Map>} + */ +const contextualShortcuts = new Map(); + +/** + * Estado del modal de ayuda + */ +let helpModalVisible = false; + +/** + * Inicializa los atajos de teclado predeterminados + */ +export function initDefaultShortcuts() { + // Atajos globales + registerShortcut({ + key: 'Alt+Shift+H', + description: __('Mostrar ayuda de atajos de teclado'), + handler: showKeyboardShortcutsHelp + }); + + registerShortcut({ + key: 'Alt+Shift+C', + description: __('Enfocar el área de composición de mensajes'), + handler: focusMessageComposer + }); + + registerShortcut({ + key: 'Alt+Shift+L', + description: __('Enfocar la lista de chats'), + handler: focusChatList + }); + + registerShortcut({ + key: 'Alt+Shift+M', + description: __('Enfocar el último mensaje'), + handler: focusLastMessage + }); + + registerShortcut({ + key: 'Alt+Shift+N', + description: __('Ir al siguiente chat con mensajes no leídos'), + handler: focusNextUnreadChat + }); + + registerShortcut({ + key: 'Alt+Shift+P', + description: __('Ir al chat anterior'), + handler: focusPreviousChat + }); + + registerShortcut({ + key: 'Alt+Shift+S', + description: __('Buscar contactos'), + handler: focusContactSearch + }); + + registerShortcut({ + key: 'Escape', + description: __('Cerrar modal o diálogo abierto'), + handler: closeCurrentModal + }); + + // Atajos contextuales para el compositor de mensajes + registerShortcut({ + key: 'Ctrl+Enter', + description: __('Enviar mensaje'), + context: 'message-composer', + handler: sendMessage + }); + + registerShortcut({ + key: 'Alt+Shift+E', + description: __('Insertar emoji'), + context: 'message-composer', + handler: toggleEmojiPicker + }); + + registerShortcut({ + key: 'Alt+Shift+F', + description: __('Adjuntar archivo'), + context: 'message-composer', + handler: triggerFileUpload + }); + + // Atajos para navegación en mensajes + registerShortcut({ + key: 'Alt+ArrowUp', + description: __('Mensaje anterior'), + context: 'chat-messages', + handler: focusPreviousMessage + }); + + registerShortcut({ + key: 'Alt+ArrowDown', + description: __('Mensaje siguiente'), + context: 'chat-messages', + handler: focusNextMessage + }); + + registerShortcut({ + key: 'Alt+Shift+R', + description: __('Responder al mensaje enfocado'), + context: 'chat-messages', + handler: replyToMessage + }); +} + +/** + * Registra un atajo de teclado + * @param {KeyboardShortcut} shortcut + */ +export function registerShortcut(shortcut) { + const { key, context } = shortcut; + + if (context) { + if (!contextualShortcuts.has(context)) { + contextualShortcuts.set(context, new Map()); + } + contextualShortcuts.get(context).set(key, shortcut); + } else { + globalShortcuts.set(key, shortcut); + } +} + +/** + * Desregistra un atajo de teclado + * @param {string} key + * @param {string} [context] + */ +export function unregisterShortcut(key, context) { + if (context) { + contextualShortcuts.get(context)?.delete(key); + } else { + globalShortcuts.delete(key); + } +} + +/** + * Maneja eventos de teclado + * @param {KeyboardEvent} event + */ +export function handleKeyboardEvent(event) { + // Ignorar si estamos en un campo de texto (excepto para atajos específicos) + const target = /** @type {HTMLElement} */ (event.target); + const isTextField = ['INPUT', 'TEXTAREA'].includes(target.tagName); + + const key = getKeyString(event); + + // Verificar contexto actual + const context = getCurrentContext(target); + + // Buscar atajo contextual primero + if (context) { + const contextShortcuts = contextualShortcuts.get(context); + const shortcut = contextShortcuts?.get(key); + + if (shortcut) { + event.preventDefault(); + shortcut.handler(event); + return; + } + } + + // Luego buscar atajo global (excepto en campos de texto) + if (!isTextField || key.includes('Alt+') || key.includes('Ctrl+')) { + const shortcut = globalShortcuts.get(key); + + if (shortcut) { + event.preventDefault(); + shortcut.handler(event); + } + } +} + +/** + * Convierte un KeyboardEvent en string de tecla + * @param {KeyboardEvent} event + * @returns {string} + */ +function getKeyString(event) { + const parts = []; + + if (event.ctrlKey) parts.push('Ctrl'); + if (event.altKey) parts.push('Alt'); + if (event.shiftKey) parts.push('Shift'); + if (event.metaKey) parts.push('Meta'); + + // Normalizar nombres de teclas + const key = event.key === ' ' ? 'Space' : event.key; + parts.push(key); + + return parts.join('+'); +} + +/** + * Obtiene el contexto actual basado en el elemento enfocado + * @param {HTMLElement} element + * @returns {string|null} + */ +function getCurrentContext(element) { + if (element.classList.contains('chat-textarea')) { + return 'message-composer'; + } + + if (element.closest('.chat-content')) { + return 'chat-messages'; + } + + if (element.closest('.list-container.roster-contacts')) { + return 'contacts-list'; + } + + return null; +} + +// ===== Implementación de handlers ===== + +/** + * Muestra el modal de ayuda de atajos + */ +function showKeyboardShortcutsHelp() { + if (helpModalVisible) { + closeKeyboardShortcutsHelp(); + return; + } + + const shortcuts = []; + + // Agregar atajos globales + globalShortcuts.forEach((shortcut) => { + shortcuts.push({ + key: shortcut.key, + description: shortcut.description, + context: __('Global') + }); + }); + + // Agregar atajos contextuales + contextualShortcuts.forEach((contextMap, context) => { + contextMap.forEach((shortcut) => { + shortcuts.push({ + key: shortcut.key, + description: shortcut.description, + context: context + }); + }); + }); + + api.modal.show('converse-keyboard-shortcuts-modal', { shortcuts }); + helpModalVisible = true; + announceToScreenReader(__('Ayuda de atajos de teclado abierta')); +} + +/** + * Cierra el modal de ayuda + */ +function closeKeyboardShortcutsHelp() { + api.modal.close(); + helpModalVisible = false; +} + +/** + * Enfoca el compositor de mensajes + */ +function focusMessageComposer() { + const activeChat = getActiveChat(); + if (!activeChat) { + announceToScreenReader(__('No hay un chat activo')); + return; + } + + const textarea = activeChat.querySelector('.chat-textarea'); + if (textarea) { + moveFocusTo(textarea, { + announce: __('Área de composición de mensajes enfocada') + }); + } +} + +/** + * Enfoca la lista de chats + */ +function focusChatList() { + const chatList = document.querySelector('#converse-roster'); + if (chatList) { + const firstChat = chatList.querySelector('.list-item'); + if (firstChat) { + moveFocusTo(firstChat, { + announce: __('Lista de chats enfocada') + }); + } + } else { + announceToScreenReader(__('Lista de chats no disponible')); + } +} + +/** + * Enfoca el último mensaje + */ +function focusLastMessage() { + const activeChat = getActiveChat(); + if (!activeChat) return; + + const messages = activeChat.querySelectorAll('.chat-msg'); + if (messages.length > 0) { + const lastMessage = messages[messages.length - 1]; + moveFocusTo(lastMessage, { + announce: __('Último mensaje enfocado') + }); + } +} + +/** + * Va al siguiente chat con mensajes no leídos + */ +function focusNextUnreadChat() { + const chats = Array.from(document.querySelectorAll('.list-item.unread-msgs')); + + if (chats.length === 0) { + announceToScreenReader(__('No hay chats con mensajes no leídos')); + return; + } + + const currentFocus = document.activeElement; + const currentIndex = chats.indexOf(currentFocus); + const nextIndex = (currentIndex + 1) % chats.length; + + moveFocusTo(chats[nextIndex], { + announce: __('Chat con mensajes no leídos') + }); + + // Abrir el chat + chats[nextIndex].click(); +} + +/** + * Va al chat anterior + */ +function focusPreviousChat() { + const chats = Array.from(document.querySelectorAll('.list-item')); + + if (chats.length === 0) return; + + const currentFocus = document.activeElement; + const currentIndex = chats.indexOf(currentFocus); + const prevIndex = currentIndex > 0 ? currentIndex - 1 : chats.length - 1; + + moveFocusTo(chats[prevIndex]); +} + +/** + * Enfoca el campo de búsqueda de contactos + */ +function focusContactSearch() { + const searchField = document.querySelector('.roster-filter'); + if (searchField) { + moveFocusTo(searchField, { + announce: __('Búsqueda de contactos') + }); + } +} + +/** + * Cierra el modal o diálogo actual + * @param {KeyboardEvent} event + */ +function closeCurrentModal(event) { + const modal = document.querySelector('.modal.show'); + if (modal) { + event.preventDefault(); + api.modal.close(); + announceToScreenReader(__('Diálogo cerrado')); + } +} + +/** + * Envía el mensaje actual + */ +function sendMessage() { + const activeChat = getActiveChat(); + if (!activeChat) return; + + const form = activeChat.querySelector('.sendXMPPMessage'); + if (form) { + const submitBtn = form.querySelector('[type="submit"]'); + submitBtn?.click(); + } +} + +/** + * Alterna el selector de emoji + */ +function toggleEmojiPicker() { + const activeChat = getActiveChat(); + if (!activeChat) return; + + const emojiButton = activeChat.querySelector('.toggle-emojis'); + if (emojiButton) { + emojiButton.click(); + announceToScreenReader(__('Selector de emoji')); + } +} + +/** + * Activa la carga de archivo + */ +function triggerFileUpload() { + const activeChat = getActiveChat(); + if (!activeChat) return; + + const fileInput = activeChat.querySelector('input[type="file"]'); + if (fileInput) { + fileInput.click(); + announceToScreenReader(__('Selector de archivo')); + } +} + +/** + * Enfoca el mensaje anterior + */ +function focusPreviousMessage() { + const currentMessage = document.activeElement?.closest('.chat-msg'); + if (!currentMessage) { + focusLastMessage(); + return; + } + + const prevMessage = currentMessage.previousElementSibling; + if (prevMessage && prevMessage.classList.contains('chat-msg')) { + moveFocusTo(prevMessage); + } +} + +/** + * Enfoca el siguiente mensaje + */ +function focusNextMessage() { + const currentMessage = document.activeElement?.closest('.chat-msg'); + if (!currentMessage) return; + + const nextMessage = currentMessage.nextElementSibling; + if (nextMessage && nextMessage.classList.contains('chat-msg')) { + moveFocusTo(nextMessage); + } +} + +/** + * Responde al mensaje enfocado + */ +function replyToMessage() { + const currentMessage = document.activeElement?.closest('.chat-msg'); + if (!currentMessage) return; + + const quoteButton = currentMessage.querySelector('.chat-msg__action-quote'); + if (quoteButton) { + quoteButton.click(); + announceToScreenReader(__('Respondiendo al mensaje')); + } +} + +/** + * Obtiene el chat activo + * @returns {HTMLElement|null} + */ +function getActiveChat() { + return document.querySelector('.chatbox:not(.hidden)'); +} + +/** + * Inicializa el sistema de atajos de teclado + */ +export function initKeyboardShortcuts() { + initDefaultShortcuts(); + document.addEventListener('keydown', handleKeyboardEvent); + + // Anunciar que los atajos están disponibles + announceToScreenReader( + __('Atajos de teclado habilitados. Presione Alt+Shift+H para ver la ayuda'), + 'polite', + 2000 + ); +} + +/** + * Deshabilita el sistema de atajos de teclado + */ +export function disableKeyboardShortcuts() { + document.removeEventListener('keydown', handleKeyboardEvent); + globalShortcuts.clear(); + contextualShortcuts.clear(); +} + +export default { + initKeyboardShortcuts, + disableKeyboardShortcuts, + registerShortcut, + unregisterShortcut, + handleKeyboardEvent +}; diff --git a/src/plugins/accessibility/modal.js b/src/plugins/accessibility/modal.js new file mode 100644 index 0000000000..3eb4199e4b --- /dev/null +++ b/src/plugins/accessibility/modal.js @@ -0,0 +1,120 @@ +/** + * @module accessibility/modal + * @description Modal para mostrar los atajos de teclado disponibles + */ + +import { html } from 'lit'; +import { api } from '@converse/headless'; +import { __ } from 'i18n'; +import BaseModal from 'plugins/modal/modal.js'; +import 'shared/components/icons.js'; + +export default class KeyboardShortcutsModal extends BaseModal { + + initialize() { + super.initialize(); + this.shortcuts = this.model.get('shortcuts') || []; + } + + renderModal() { + const grouped = this.groupShortcutsByContext(); + + return html` + + `; + } + + groupShortcutsByContext() { + const grouped = {}; + + this.shortcuts.forEach(shortcut => { + const context = shortcut.context || __('General'); + if (!grouped[context]) { + grouped[context] = []; + } + grouped[context].push(shortcut); + }); + + return grouped; + } + + formatShortcutKey(key) { + // Reemplazar símbolos con representaciones más legibles + return key + .replace(/\+/g, ' + ') + .replace('Alt', '⎇ Alt') + .replace('Ctrl', '⌃ Ctrl') + .replace('Shift', '⇧ Shift') + .replace('Meta', '⌘ Meta') + .replace('ArrowUp', '↑') + .replace('ArrowDown', '↓') + .replace('ArrowLeft', '←') + .replace('ArrowRight', '→') + .replace('Enter', '↵ Enter') + .replace('Space', '␣ Espacio'); + } +} + +api.elements.define('converse-keyboard-shortcuts-modal', KeyboardShortcutsModal); diff --git a/src/plugins/accessibility/settings-panel.js b/src/plugins/accessibility/settings-panel.js new file mode 100644 index 0000000000..fe3b2c884a --- /dev/null +++ b/src/plugins/accessibility/settings-panel.js @@ -0,0 +1,301 @@ +/** + * Panel de configuración de accesibilidad para el modal de ajustes + */ +import { CustomElement } from 'shared/components/element.js'; +import { api } from '@converse/headless'; +import { html } from 'lit'; +import { __ } from 'i18n'; + +import './styles/accessibility-settings.scss'; + +export default class AccessibilitySettings extends CustomElement { + + static get properties() { + return { + settings: { type: Object } + }; + } + + constructor() { + super(); + this.settings = {}; + } + + connectedCallback() { + super.connectedCallback(); + this.loadSettings(); + } + + loadSettings() { + this.settings = { + enable_accessibility: api.settings.get('enable_accessibility'), + enable_keyboard_shortcuts: api.settings.get('enable_keyboard_shortcuts'), + enable_screen_reader_announcements: api.settings.get('enable_screen_reader_announcements'), + announce_new_messages: api.settings.get('announce_new_messages'), + announce_status_changes: api.settings.get('announce_status_changes'), + focus_on_new_message: api.settings.get('focus_on_new_message'), + high_contrast_mode: api.settings.get('high_contrast_mode'), + enable_voice_messages: api.settings.get('enable_voice_messages') + }; + this.requestUpdate(); + } + + render() { + return html` +
+
+

${__('Configuración de Accesibilidad')}

+

+ ${__('Personaliza las opciones de accesibilidad para mejorar tu experiencia')} +

+
+ +
+

${__('Funciones Generales')}

+ +
+ this.updateSetting('enable_accessibility', e.target.checked)} + /> + +
+ +
+ this.updateSetting('high_contrast_mode', e.target.checked)} + /> + +
+
+ +
+

${__('Atajos de Teclado')}

+ +
+ this.updateSetting('enable_keyboard_shortcuts', e.target.checked)} + /> + +
+ + ${this.settings.enable_keyboard_shortcuts ? html` +
+ +
+ ` : ''} +
+ +
+

${__('Lectores de Pantalla')}

+ +
+ this.updateSetting('enable_screen_reader_announcements', e.target.checked)} + /> + +
+ +
+ this.updateSetting('announce_new_messages', e.target.checked)} + /> + +
+ +
+ this.updateSetting('announce_status_changes', e.target.checked)} + /> + +
+ +
+ this.updateSetting('focus_on_new_message', e.target.checked)} + /> + +
+
+ +
+

${__('Mensajes de Voz')}

+ +
+ this.updateSetting('enable_voice_messages', e.target.checked)} + /> + +
+ + ${this.settings.enable_voice_messages ? html` +
+ + ${__('Atajos durante grabación:')}
+ • Space: ${__('Pausar/reanudar')}
+ • Enter: ${__('Detener y enviar')}
+ • Escape: ${__('Cancelar')}

+ ${__('Atajos durante reproducción:')}
+ • k: ${__('Play/pause')}
+ • j/l: ${__('Retroceder/adelantar 10s')}
+ • ←/→: ${__('Retroceder/adelantar 5s')} +
+
+ ` : ''} +
+ + +
+ `; + } + + updateSetting(key, value) { + try { + // Actualizar el setting + api.settings.set(key, value); + + // Actualizar el estado local + this.settings[key] = value; + this.requestUpdate(); + + // Aplicar cambios específicos + if (key === 'high_contrast_mode') { + this.toggleHighContrast(value); + } + + // Anunciar el cambio + if (api.accessibility) { + const setting_name = this.getSettingName(key); + const status = value ? __('activado') : __('desactivado'); + api.accessibility.announce( + __('%1$s %2$s', setting_name, status), + 'polite' + ); + } + + // Guardar en localStorage para persistencia + localStorage.setItem(`converse-${key}`, JSON.stringify(value)); + + } catch (error) { + console.error('Error al actualizar configuración:', error); + } + } + + getSettingName(key) { + const names = { + 'enable_accessibility': __('Accesibilidad'), + 'enable_keyboard_shortcuts': __('Atajos de teclado'), + 'enable_screen_reader_announcements': __('Anuncios de lector de pantalla'), + 'announce_new_messages': __('Anunciar mensajes nuevos'), + 'announce_status_changes': __('Anunciar cambios de estado'), + 'focus_on_new_message': __('Enfocar mensajes nuevos'), + 'high_contrast_mode': __('Modo de alto contraste'), + 'enable_voice_messages': __('Mensajes de voz') + }; + return names[key] || key; + } + + toggleHighContrast(enabled) { + if (enabled) { + document.body.classList.add('converse-high-contrast'); + } else { + document.body.classList.remove('converse-high-contrast'); + } + } + + showShortcutsModal() { + if (api.accessibility && api.accessibility.showShortcutsModal) { + api.accessibility.showShortcutsModal(); + } + } +} + +api.elements.define('converse-accessibility-settings', AccessibilitySettings); diff --git a/src/plugins/accessibility/styles/accessibility-settings.scss b/src/plugins/accessibility/styles/accessibility-settings.scss new file mode 100644 index 0000000000..a773b1b2f9 --- /dev/null +++ b/src/plugins/accessibility/styles/accessibility-settings.scss @@ -0,0 +1,211 @@ +/** + * Estilos para el panel de configuración de accesibilidad + */ + +.accessibility-settings { + padding: 1.5rem; + max-width: 800px; + margin: 0 auto; +} + +.settings-header { + margin-bottom: 2rem; + padding-bottom: 1rem; + border-bottom: 2px solid var(--border-color, #dee2e6); + + h3 { + margin: 0 0 0.5rem 0; + color: var(--text-color, #333); + font-size: 1.5rem; + } + + .text-muted { + margin: 0; + font-size: 0.9rem; + } +} + +.settings-section { + margin-bottom: 2rem; + padding: 1rem; + background: var(--chat-background-color, #f8f9fa); + border-radius: 8px; + + h4 { + margin: 0 0 1rem 0; + color: var(--primary-color, #0066cc); + font-size: 1.1rem; + font-weight: 600; + } + + .form-check { + margin-bottom: 1.25rem; + padding: 0.75rem; + background: var(--message-background, #fff); + border-radius: 6px; + border: 1px solid var(--border-color, #dee2e6); + transition: all 0.2s ease; + + &:hover { + border-color: var(--primary-color, #0066cc); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + } + + &:last-child { + margin-bottom: 0; + } + } + + .form-check-input { + width: 1.25rem; + height: 1.25rem; + margin-top: 0.125rem; + cursor: pointer; + + &:focus { + border-color: var(--primary-color, #0066cc); + box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.25); + } + + &:disabled { + cursor: not-allowed; + opacity: 0.5; + } + } + + .form-check-label { + display: block; + cursor: pointer; + padding-left: 0.5rem; + + strong { + display: block; + margin-bottom: 0.25rem; + color: var(--text-color, #333); + font-size: 0.95rem; + } + + .form-text { + display: block; + font-size: 0.85rem; + line-height: 1.4; + margin: 0; + } + } +} + +.keyboard-shortcuts-info, +.voice-messages-info { + margin-top: 1rem; + padding: 1rem; + background: var(--message-background, #fff); + border-left: 3px solid var(--primary-color, #0066cc); + border-radius: 4px; + + .btn { + margin-top: 0.5rem; + } + + .form-text { + margin: 0; + line-height: 1.6; + } +} + +.settings-footer { + margin-top: 2rem; + + .alert { + border-radius: 8px; + padding: 1rem; + margin: 0; + + strong { + display: inline-block; + margin-right: 0.5rem; + } + } +} + +/* Modo oscuro */ +@media (prefers-color-scheme: dark) { + .accessibility-settings { + .settings-section { + background: rgba(255, 255, 255, 0.05); + + .form-check { + background: rgba(0, 0, 0, 0.2); + border-color: rgba(255, 255, 255, 0.1); + } + } + + .keyboard-shortcuts-info, + .voice-messages-info { + background: rgba(0, 0, 0, 0.2); + } + } +} + +/* Modo de alto contraste */ +body.converse-high-contrast { + .accessibility-settings { + .settings-section { + border: 2px solid #000; + + .form-check { + border: 2px solid #000; + + &:hover { + border-color: #0066cc; + box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.3); + } + } + } + + .form-check-input { + border: 2px solid #000; + + &:checked { + background-color: #0066cc; + border-color: #0066cc; + } + } + + .keyboard-shortcuts-info, + .voice-messages-info { + border: 2px solid #000; + } + } +} + +/* Responsive */ +@media (max-width: 768px) { + .accessibility-settings { + padding: 1rem; + } + + .settings-header { + h3 { + font-size: 1.25rem; + } + } + + .settings-section { + padding: 0.75rem; + + h4 { + font-size: 1rem; + } + + .form-check { + padding: 0.5rem; + } + } +} + +/* Animaciones reducidas */ +@media (prefers-reduced-motion: reduce) { + .settings-section .form-check { + transition: none; + } +} diff --git a/src/plugins/accessibility/styles/accessibility.scss b/src/plugins/accessibility/styles/accessibility.scss new file mode 100644 index 0000000000..5439aeb682 --- /dev/null +++ b/src/plugins/accessibility/styles/accessibility.scss @@ -0,0 +1,383 @@ +/** + * Estilos de accesibilidad para Converse.js + * Mejoras visuales para usuarios con necesidades de accesibilidad + */ + +/* Clase solo para lectores de pantalla */ +.sr-only, +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.sr-only-focusable:focus, +.sr-only-focusable:active { + position: static; + width: auto; + height: auto; + overflow: visible; + clip: auto; + white-space: normal; +} + +/* Skip links - Enlaces de salto para navegación rápida */ +.skip-link { + position: absolute; + top: -40px; + left: 0; + background: var(--primary-color, #0066cc); + color: white; + padding: 8px 16px; + text-decoration: none; + z-index: 10000; + border-radius: 0 0 4px 0; + font-weight: bold; + + &:focus { + top: 0; + outline: 3px solid var(--focus-outline-color, #ffbf47); + outline-offset: 2px; + } +} + +/* Mejoras de enfoque visible */ +*:focus-visible, +*:focus { + outline: 2px solid var(--focus-outline-color, #0066cc); + outline-offset: 2px; +} + +button:focus-visible, +a:focus-visible, +input:focus-visible, +textarea:focus-visible, +select:focus-visible { + outline: 3px solid var(--focus-outline-color, #0066cc); + outline-offset: 2px; +} + +/* Indicador de enfoque para mensajes */ +.chat-msg:focus-visible { + outline: 3px solid var(--focus-outline-color, #0066cc); + outline-offset: -3px; + background-color: var(--focus-background-color, rgba(0, 102, 204, 0.1)); +} + +/* Mejoras de contraste para enlaces */ +a { + text-decoration: underline; + text-decoration-thickness: 1px; + text-underline-offset: 2px; + + &:hover, + &:focus { + text-decoration-thickness: 2px; + } +} + +/* Botones más accesibles */ +button, +.btn { + min-height: 44px; + min-width: 44px; + position: relative; + + &:focus-visible::after { + content: ''; + position: absolute; + inset: -3px; + border: 3px solid var(--focus-outline-color, #0066cc); + border-radius: inherit; + pointer-events: none; + } +} + +/* Atajos de teclado visibles */ +kbd, +.shortcut-key { + display: inline-block; + padding: 3px 8px; + font-family: 'Courier New', Courier, monospace; + font-size: 0.9em; + font-weight: 600; + line-height: 1.4; + color: var(--text-color, #333); + background-color: var(--kbd-background, #f4f4f4); + border: 1px solid var(--kbd-border, #ccc); + border-radius: 4px; + box-shadow: 0 1px 0 var(--kbd-shadow, rgba(0, 0, 0, 0.2)); + white-space: nowrap; +} + +/* Modo de alto contraste */ +body.converse-high-contrast { + --text-color: #000; + --background-color: #fff; + --border-color: #000; + --focus-outline-color: #000; + --focus-background-color: #ff0; + --link-color: #00f; + --link-visited-color: #800080; + + /* Aumentar contraste en todos los elementos */ + * { + border-color: var(--border-color) !important; + } + + /* Enlaces más visibles */ + a { + color: var(--link-color) !important; + text-decoration: underline !important; + text-decoration-thickness: 2px !important; + + &:visited { + color: var(--link-visited-color) !important; + } + + &:hover, + &:focus { + background-color: var(--focus-background-color) !important; + color: #000 !important; + } + } + + /* Botones con mejor contraste */ + button, + .btn { + background-color: #fff !important; + color: #000 !important; + border: 2px solid #000 !important; + + &:hover, + &:focus { + background-color: #ff0 !important; + color: #000 !important; + } + + &:disabled { + opacity: 0.5; + } + } + + /* Inputs con borde fuerte */ + input, + textarea, + select { + background-color: #fff !important; + color: #000 !important; + border: 2px solid #000 !important; + + &:focus { + background-color: #ffc !important; + outline: 3px solid #000 !important; + } + } + + /* Mensajes más legibles */ + .chat-msg { + border: 1px solid #000 !important; + background-color: #fff !important; + + &.mentioned { + background-color: #ff0 !important; + border-color: #000 !important; + border-width: 2px !important; + } + } + + /* Iconos visibles */ + svg, + .converse-icon { + filter: contrast(1.5); + } +} + +/* Animaciones reducidas para usuarios que lo prefieran */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} + +/* Mejoras para usuarios con daltonismo */ +@media (prefers-color-scheme: dark) { + :root { + --focus-outline-color: #4d9fff; + } +} + +/* Estilos para el modal de atajos de teclado */ +.shortcuts-section { + margin-bottom: 1.5rem; + + h5 { + font-size: 1.1rem; + font-weight: 600; + margin-bottom: 0.75rem; + padding-bottom: 0.5rem; + border-bottom: 2px solid var(--primary-color, #0066cc); + } +} + +.shortcut-description { + font-size: 0.95rem; + color: var(--text-color, #333); +} + +.shortcut-key { + font-size: 0.85rem; + white-space: nowrap; +} + +/* Indicador de mensajes no leídos más visible */ +.unread-msgs { + position: relative; + font-weight: 700; + + &::before { + content: ''; + position: absolute; + left: -10px; + top: 50%; + transform: translateY(-50%); + width: 8px; + height: 8px; + background-color: var(--notification-color, #e91e63); + border-radius: 50%; + animation: pulse 2s infinite; + } +} + +@keyframes pulse { + 0%, 100% { + opacity: 1; + transform: translateY(-50%) scale(1); + } + 50% { + opacity: 0.7; + transform: translateY(-50%) scale(1.1); + } +} + +/* Tooltips más accesibles */ +[title], +[aria-label] { + position: relative; + + &:hover::after, + &:focus::after { + content: attr(title) attr(aria-label); + position: absolute; + bottom: 100%; + left: 50%; + transform: translateX(-50%); + padding: 4px 8px; + background-color: rgba(0, 0, 0, 0.9); + color: white; + font-size: 0.85rem; + white-space: nowrap; + border-radius: 4px; + z-index: 1000; + pointer-events: none; + margin-bottom: 4px; + } +} + +/* Mejoras para tablas de datos */ +table { + caption { + font-weight: 600; + text-align: left; + padding: 0.5rem 0; + caption-side: top; + } + + th { + font-weight: 600; + text-align: left; + } +} + +/* Estados de carga accesibles */ +.loading-spinner { + &::after { + content: attr(aria-label) attr(aria-valuetext); + position: absolute; + left: -10000px; + } +} + +/* Región de anuncios para lectores de pantalla */ +#converse-live-region { + position: absolute; + left: -10000px; + width: 1px; + height: 1px; + overflow: hidden; +} + +/* Focus trap visual indicator */ +.focus-trapped { + box-shadow: inset 0 0 0 3px var(--focus-outline-color, #0066cc); +} + +/* Estados de error más visibles */ +.error, +.has-error, +[aria-invalid="true"] { + border-color: var(--error-color, #d32f2f) !important; + background-color: var(--error-background, #fdecea) !important; + + &:focus { + outline-color: var(--error-color, #d32f2f) !important; + } +} + +.error-message { + color: var(--error-color, #d32f2f); + font-weight: 600; + margin-top: 0.25rem; + display: flex; + align-items: center; + gap: 0.5rem; + + &::before { + content: '⚠'; + font-size: 1.2em; + } +} + +/* Estados de éxito */ +.success, +[aria-live="polite"].success { + border-color: var(--success-color, #388e3c) !important; + background-color: var(--success-background, #e8f5e9) !important; +} + +/* Mejoras de contraste para badges */ +.badge { + font-weight: 600; + padding: 0.35em 0.65em; + border: 1px solid currentColor; +} + +/* Lista de contactos más accesible */ +.roster-contact { + &:focus-within { + background-color: var(--focus-background-color, rgba(0, 102, 204, 0.1)); + outline: 2px solid var(--focus-outline-color, #0066cc); + outline-offset: -2px; + } +} diff --git a/src/plugins/chatview/bottom-panel.js b/src/plugins/chatview/bottom-panel.js index fc810d76c9..8c46beb146 100644 --- a/src/plugins/chatview/bottom-panel.js +++ b/src/plugins/chatview/bottom-panel.js @@ -3,7 +3,8 @@ * @typedef {import('shared/chat/emoji-dropdown.js').default} EmojiDropdown * @typedef {import('./message-form.js').default} MessageForm */ -import { _converse, api } from '@converse/headless'; +import { api } from '@converse/headless'; +import { __ } from 'i18n'; import { CustomElement } from 'shared/components/element.js'; import tplBottomPanel from './templates/bottom-panel.js'; import { clearMessages } from './utils.js'; @@ -35,6 +36,7 @@ export default class ChatBottomPanel extends CustomElement { await this.model.initialized; this.listenTo(this.model, 'change:num_unread', () => this.requestUpdate()); this.listenTo(this.model, 'emoji-picker-autocomplete', this.autocompleteInPicker); + this.listenTo(this.model, 'startVoiceRecording', () => this.showVoiceRecorder()); this.addEventListener('emojipickerblur', () => /** @type {HTMLElement} */ (this.querySelector('.chat-textarea')).focus() @@ -46,9 +48,75 @@ export default class ChatBottomPanel extends CustomElement { return tplBottomPanel({ 'model': this.model, 'viewUnreadMessages': (ev) => this.viewUnreadMessages(ev), + 'show_voice_recorder': this.model.get('show_voice_recorder') || false, + 'handleRecordingCompleted': (e) => this.handleRecordingCompleted(e), + 'hideVoiceRecorder': () => this.hideVoiceRecorder(), }); } + showVoiceRecorder() { + this.model.set('show_voice_recorder', true); + this.requestUpdate(); + + // Esperar a que se renderice y luego enfocar + setTimeout(() => { + const recorder = /** @type {HTMLElement} */ (this.querySelector('converse-audio-recorder')); + if (recorder) { + recorder.focus(); + } + }, 100); + } + + hideVoiceRecorder() { + this.model.set('show_voice_recorder', false); + this.requestUpdate(); + } + + async handleRecordingCompleted(event) { + const { audioBlob, duration } = event.detail; + + try { + // Crear archivo de audio + if (!api.voice_messages || !api.voice_messages.createAudioFile) { + throw new Error('API de mensajes de voz no disponible'); + } + + const file = api.voice_messages.createAudioFile(audioBlob); + + // Anunciar a lectores de pantalla + if (api.accessibility && api.accessibility.announce) { + api.accessibility.announce( + __('Enviando mensaje de voz de %1$s segundos', Math.round(duration)), + 'polite' + ); + } + + // Enviar usando el método del modelo + await this.model.sendFiles([file]); + + // Ocultar el grabador + this.hideVoiceRecorder(); + + // Confirmar envío + if (api.accessibility && api.accessibility.announce) { + api.accessibility.announce( + __('Mensaje de voz enviado correctamente'), + 'assertive' + ); + } + } catch (error) { + console.error('Error al enviar mensaje de voz:', error); + + // Anunciar error + if (api.accessibility && api.accessibility.announce) { + api.accessibility.announce( + __('Error al enviar mensaje de voz: %1$s', error.message), + 'assertive' + ); + } + } + } + viewUnreadMessages(ev) { ev?.preventDefault?.(); this.model.ui.set({ 'scrolled': false }); diff --git a/src/plugins/chatview/index.js b/src/plugins/chatview/index.js index a9fa389d0d..acd156970c 100644 --- a/src/plugins/chatview/index.js +++ b/src/plugins/chatview/index.js @@ -54,7 +54,8 @@ converse.plugins.add('converse-chatview', { 'call': false, 'clear': true, 'emoji': true, - 'spoiler': false + 'spoiler': false, + 'voice_message': true } }); diff --git a/src/plugins/chatview/templates/bottom-panel.js b/src/plugins/chatview/templates/bottom-panel.js index 2968d2e544..e689f22b2e 100644 --- a/src/plugins/chatview/templates/bottom-panel.js +++ b/src/plugins/chatview/templates/bottom-panel.js @@ -1,12 +1,27 @@ import { __ } from 'i18n'; +import { api } from '@converse/headless'; import { html } from 'lit'; export default (o) => { const unread_msgs = __('You have unread messages'); + const max_duration = api.settings.get('max_voice_message_duration') || 300; + const bitrate = api.settings.get('voice_message_bitrate') || 128000; + return html` ${ o.model.ui.get('scrolled') && o.model.get('num_unread') ? html`
o.viewUnreadMessages(ev)}>▼ ${ unread_msgs } ▼
` : '' } + + ${ o.show_voice_recorder ? html` + + ` : '' } + `; } diff --git a/src/plugins/chatview/templates/chat.js b/src/plugins/chatview/templates/chat.js index 795d47e923..c464bb745b 100644 --- a/src/plugins/chatview/templates/chat.js +++ b/src/plugins/chatview/templates/chat.js @@ -5,16 +5,20 @@ import { getChatStyle } from 'shared/chat/utils'; const { CHATROOMS_TYPE } = constants; -/** - * @param {import('../chat').default} el - */ export default (el) => { const help_messages = el.getHelpMessages(); const show_help_messages = el.model.get('show_help_messages'); const is_overlayed = api.settings.get('view_mode') === 'overlayed'; const style = getChatStyle(el.model); + const contact_name = el.model.getDisplayName?.() || el.model.get('jid'); + return html` -
+ - +
` diff --git a/src/plugins/chatview/templates/message-form.js b/src/plugins/chatview/templates/message-form.js index b5bbcfe41b..eab9c746a3 100644 --- a/src/plugins/chatview/templates/message-form.js +++ b/src/plugins/chatview/templates/message-form.js @@ -15,6 +15,7 @@ export default (el) => { const show_emoji_button = api.settings.get("visible_toolbar_buttons").emoji; const show_send_button = api.settings.get("show_send_button"); const show_spoiler_button = api.settings.get("visible_toolbar_buttons").spoiler; + const show_voice_message_button = api.settings.get("visible_toolbar_buttons").voice_message; const show_toolbar = api.settings.get("show_toolbar"); return html`
{ ?show_emoji_button="${show_emoji_button}" ?show_send_button="${show_send_button}" ?show_spoiler_button="${show_spoiler_button}" + ?show_voice_message_button="${show_voice_message_button}" ?show_toolbar="${show_toolbar}" message_limit="${message_limit}" >` diff --git a/src/plugins/profile/modals/templates/user-settings.js b/src/plugins/profile/modals/templates/user-settings.js index fabd8140b5..dd37c2c902 100644 --- a/src/plugins/profile/modals/templates/user-settings.js +++ b/src/plugins/profile/modals/templates/user-settings.js @@ -9,12 +9,16 @@ import { unsafeHTML } from 'lit/directives/unsafe-html.js'; */ const tplNavigation = (el) => { const i18n_about = __('About'); + const i18n_accessibility = __('Accessibility'); const i18n_commands = __('Commands'); const i18n_services = __('Services'); const show_client_info = api.settings.get('show_client_info'); + const enable_accessibility = api.settings.get('enable_accessibility'); const allow_adhoc_commands = api.settings.get('allow_adhoc_commands'); const has_disco_browser = _converse.pluggable.plugins['converse-disco-views']?.enabled(_converse); - const show_tabs = (show_client_info ? 1 : 0) + (allow_adhoc_commands ? 1 : 0) + (has_disco_browser ? 1 : 0) >= 2; + const tab_count = (show_client_info ? 1 : 0) + (enable_accessibility ? 1 : 0) + + (allow_adhoc_commands ? 1 : 0) + (has_disco_browser ? 1 : 0); + const show_tabs = tab_count >= 2; return html` ${show_tabs ? html` - + ${bars}`; } - // ===== Métodos de reproducción ===== + // ===== Playback methods ===== initAudioElement() { this.updateComplete.then(() => { @@ -265,7 +265,7 @@ export default class AudioPlayer extends CustomElement { onSeekEnd(event) { const time = parseFloat(event.target.value); announceToScreenReader( - __('Posición: %1$s', this.formatTime(time)), + __('Position: %1$s', this.formatTime(time)), 'polite' ); } @@ -325,7 +325,7 @@ export default class AudioPlayer extends CustomElement { if (this.audioElement) { this.audioElement.currentTime = 0; } - announceToScreenReader(__('Reproducción finalizada'), 'polite'); + announceToScreenReader(__('Playback finished'), 'polite'); } onError(event) { @@ -371,7 +371,7 @@ export default class AudioPlayer extends CustomElement { } handleKeyboard = (event) => { - // Solo procesar si el foco está en el reproductor + // Only process if focus is on the player if (!this.contains(document.activeElement)) { return; } diff --git a/src/plugins/voice-messages/audio-recorder.js b/src/plugins/voice-messages/audio-recorder.js index a5a304806a..e32e5ff8bf 100644 --- a/src/plugins/voice-messages/audio-recorder.js +++ b/src/plugins/voice-messages/audio-recorder.js @@ -13,7 +13,7 @@ import 'shared/components/icons.js'; import './styles/audio-recorder.scss'; /** - * Estados de la grabación + * Recording states */ const RecordingState = { IDLE: 'idle', @@ -25,7 +25,7 @@ const RecordingState = { }; /** - * Componente de grabación de audio accesible + * Accessible audio recording component */ export default class AudioRecorder extends CustomElement { @@ -100,7 +100,7 @@ export default class AudioRecorder extends CustomElement { type="button" class="btn btn-primary btn-record" @click=${this.startRecording} - aria-label="${__('Iniciar grabación de mensaje de voz')}" + aria-label="${__('Start recording voice message')}" title="${__('Grabar mensaje de voz (Alt+Shift+V)')}" >
- ${__('Solicitando permiso de micrófono...')} + ${__('Requesting microphone permission...')}

- ${__('Solicitando acceso al micrófono...')} + ${__('Requesting microphone access...')}

`; @@ -134,7 +134,7 @@ export default class AudioRecorder extends CustomElement { const formattedDuration = this.formatDuration(this.duration); return html` -
+
-