refactor(ui): Improve tooltip and chat message interaction
- Enhance tooltip accessibility and interaction - Add keyboard support and prevent multiple tooltip instances - Modify chat message nickname styling for better usability - Refactor tooltip component to use button for better semantics
- commit
- 272f39f4d96e1c66cbbc4948355645fe7cba11cc
- author
- Kit · · 2025-02-18 15:53 UTC
- parent
- 69a5042
- signature
- SSH Unverified
SHA256:H91DFuEG4jpk5edzLA4aUKkpv4zv9Q339ipRmkph6TkThe key is unknown
2 files · +58 -12
M src/lib/components/chat-room.svelte +11 -5
| @@ -535,7 +535,7 @@ function handleLoginSuccess() { | |||
| 535 | 535 | {/if} | |
| 536 | 536 | {#if showRegistrationPrompt} | |
| 537 | 537 | <div class="registration-prompt"> | |
| 538 | - | <p>👋 <button class="link-button" onclick={openSignup}>Inscris-toi</button> pour lire le reste du chat !</p> | |
| 538 | + | <p>👋 <button class="link-button" onclick={openSignup}>Inscris-toi</button>pour lire le reste du chat !</p> | |
| 539 | 539 | </div> | |
| 540 | 540 | {/if} | |
| 541 | 541 | {#each visibleMessages as message (message.id)} | |
| @@ -546,9 +546,10 @@ function handleLoginSuccess() { | |||
| 546 | 546 | text: formatFrenchDateTime(new Date(message.timestamp)), | |
| 547 | 547 | direction: "left", | |
| 548 | 548 | closeDelay: 1000, | |
| 549 | - | touchBehavior: "remove" | |
| 549 | + | touchBehavior: "remove", | |
| 550 | + | interactive: false | |
| 550 | 551 | }}> | |
| 551 | - | <span class="nickname">{message.user.nickname}</span> | |
| 552 | + | <span class="nickname pointer-events-none">{message.user.nickname}</span> | |
| 552 | 553 | </Tooltip> | |
| 553 | 554 | {message.content} | |
| 554 | 555 | </span> | |
| @@ -557,9 +558,10 @@ function handleLoginSuccess() { | |||
| 557 | 558 | text: formatFrenchDateTime(new Date(message.timestamp)), | |
| 558 | 559 | direction: "left", | |
| 559 | 560 | closeDelay: 1000, | |
| 560 | - | touchBehavior: "remove" | |
| 561 | + | touchBehavior: "remove", | |
| 562 | + | interactive: false | |
| 561 | 563 | }}> | |
| 562 | - | <span class="nickname">{message.user.nickname}:</span> | |
| 564 | + | <span class="nickname pointer-events-none">{message.user.nickname}:</span> | |
| 563 | 565 | </Tooltip> | |
| 564 | 566 | <span class="content">{message.content}</span> | |
| 565 | 567 | {/if} | |
| @@ -1020,4 +1022,8 @@ function handleLoginSuccess() { | |||
| 1020 | 1022 | gap: 2px; | |
| 1021 | 1023 | margin-left: auto; | |
| 1022 | 1024 | } | |
| 1025 | + | ||
| 1026 | + | .pointer-events-none { | |
| 1027 | + | pointer-events: none; | |
| 1028 | + | } | |
| 1023 | 1029 | </style>⏎̸ | |
| @@ -535,7 +535,7 @@ function handleLoginSuccess() { | |||||
| 535 | {/if} | 535 | {/if} | ||
| 536 | {#if showRegistrationPrompt} | 536 | {#if showRegistrationPrompt} | ||
| 537 | <div class="registration-prompt"> | 537 | <div class="registration-prompt"> | ||
| 538 | - | <p>👋 <button class="link-button" onclick={openSignup}>Inscris-toi</button> pour lire le reste du chat !</p> | 538 | + | <p>👋 <button class="link-button" onclick={openSignup}>Inscris-toi</button>pour lire le reste du chat !</p> |
| 539 | </div> | 539 | </div> | ||
| 540 | {/if} | 540 | {/if} | ||
| 541 | {#each visibleMessages as message (message.id)} | 541 | {#each visibleMessages as message (message.id)} | ||
| @@ -546,9 +546,10 @@ function handleLoginSuccess() { | |||||
| 546 | text: formatFrenchDateTime(new Date(message.timestamp)), | 546 | text: formatFrenchDateTime(new Date(message.timestamp)), | ||
| 547 | direction: "left", | 547 | direction: "left", | ||
| 548 | closeDelay: 1000, | 548 | closeDelay: 1000, | ||
| 549 | - | touchBehavior: "remove" | 549 | + | touchBehavior: "remove", |
| 550 | + | interactive: false | |||
| 550 | }}> | 551 | }}> | ||
| 551 | - | <span class="nickname">{message.user.nickname}</span> | 552 | + | <span class="nickname pointer-events-none">{message.user.nickname}</span> |
| 552 | </Tooltip> | 553 | </Tooltip> | ||
| 553 | {message.content} | 554 | {message.content} | ||
| 554 | </span> | 555 | </span> | ||
| @@ -557,9 +558,10 @@ function handleLoginSuccess() { | |||||
| 557 | text: formatFrenchDateTime(new Date(message.timestamp)), | 558 | text: formatFrenchDateTime(new Date(message.timestamp)), | ||
| 558 | direction: "left", | 559 | direction: "left", | ||
| 559 | closeDelay: 1000, | 560 | closeDelay: 1000, | ||
| 560 | - | touchBehavior: "remove" | 561 | + | touchBehavior: "remove", |
| 562 | + | interactive: false | |||
| 561 | }}> | 563 | }}> | ||
| 562 | - | <span class="nickname">{message.user.nickname}:</span> | 564 | + | <span class="nickname pointer-events-none">{message.user.nickname}:</span> |
| 563 | </Tooltip> | 565 | </Tooltip> | ||
| 564 | <span class="content">{message.content}</span> | 566 | <span class="content">{message.content}</span> | ||
| 565 | {/if} | 567 | {/if} | ||
| @@ -1020,4 +1022,8 @@ function handleLoginSuccess() { | |||||
| 1020 | gap: 2px; | 1022 | gap: 2px; | ||
| 1021 | margin-left: auto; | 1023 | margin-left: auto; | ||
| 1022 | } | 1024 | } | ||
| 1025 | + | ||||
| 1026 | + | .pointer-events-none { | |||
| 1027 | + | pointer-events: none; | |||
| 1028 | + | } | |||
| 1023 | </style>⏎̸ | 1029 | </style>⏎̸ | ||
M src/lib/components/ui/tooltip.svelte +47 -7
| @@ -12,8 +12,8 @@ | |||
| 12 | 12 | let text = $derived(data.text); | |
| 13 | 13 | let direction = $derived(data.direction ?? 'top'); | |
| 14 | 14 | ||
| 15 | - | let wrapper: HTMLDivElement; | |
| 16 | - | let tooltipDiv: HTMLDivElement | null = null; | |
| 15 | + | let wrapper: HTMLButtonElement; | |
| 16 | + | let tooltipDiv = $state<HTMLDivElement | null>(null); | |
| 17 | 17 | let coords = { x: 0, y: 0 }; | |
| 18 | 18 | ||
| 19 | 19 | function updateCoords() { | |
| @@ -33,11 +33,14 @@ | |||
| 33 | 33 | ||
| 34 | 34 | function showTooltip() { | |
| 35 | 35 | updateCoords(); | |
| 36 | + | if (tooltipDiv) return; // Prevent multiple tooltips | |
| 36 | 37 | tooltipDiv = document.createElement('div'); | |
| 37 | 38 | tooltipDiv.className = `tooltip ${direction}`; | |
| 38 | 39 | tooltipDiv.style.position = 'fixed'; | |
| 39 | 40 | tooltipDiv.style.zIndex = '1000000'; | |
| 40 | 41 | tooltipDiv.textContent = text; | |
| 42 | + | tooltipDiv.setAttribute('role', 'tooltip'); | |
| 43 | + | tooltipDiv.setAttribute('aria-hidden', 'false'); | |
| 41 | 44 | document.body.appendChild(tooltipDiv); | |
| 42 | 45 | ||
| 43 | 46 | const tooltipRect = tooltipDiv.getBoundingClientRect(); | |
| @@ -78,24 +81,61 @@ | |||
| 78 | 81 | tooltipDiv = null; | |
| 79 | 82 | } | |
| 80 | 83 | } | |
| 84 | + | ||
| 85 | + | function handleKeyDown(e: KeyboardEvent) { | |
| 86 | + | if (e.key === 'Escape') { | |
| 87 | + | hideTooltip(); | |
| 88 | + | } | |
| 89 | + | } | |
| 81 | 90 | </script> | |
| 82 | 91 | ||
| 83 | - | <div | |
| 92 | + | <button | |
| 84 | 93 | class="tooltip-wrapper" | |
| 85 | 94 | bind:this={wrapper} | |
| 86 | 95 | onmouseover={showTooltip} | |
| 87 | 96 | onmouseout={hideTooltip} | |
| 88 | 97 | onfocus={showTooltip} | |
| 89 | 98 | onblur={hideTooltip} | |
| 90 | - | role="tooltip" | |
| 91 | - | tabindex="-1" | |
| 99 | + | onkeydown={handleKeyDown} | |
| 100 | + | type="button" | |
| 101 | + | aria-describedby={tooltipDiv?.id} | |
| 92 | 102 | > | |
| 93 | 103 | {@render children()} | |
| 94 | - | </div> | |
| 104 | + | </button> | |
| 95 | 105 | ||
| 96 | 106 | <style> | |
| 97 | 107 | .tooltip-wrapper { | |
| 98 | - | display: inline-block; | |
| 108 | + | display: inline; | |
| 109 | + | width: auto; | |
| 110 | + | min-width: 0; | |
| 111 | + | background: none !important; | |
| 112 | + | border: none !important; | |
| 113 | + | padding: 0 !important; | |
| 114 | + | margin: 0 !important; | |
| 115 | + | font: inherit !important; | |
| 116 | + | color: inherit !important; | |
| 117 | + | cursor: inherit !important; | |
| 118 | + | text-align: inherit !important; | |
| 119 | + | box-shadow: none !important; | |
| 120 | + | text-shadow: inherit !important; | |
| 121 | + | border-radius: 0 !important; | |
| 122 | + | -webkit-appearance: none !important; | |
| 123 | + | appearance: none !important; | |
| 124 | + | white-space: pre; | |
| 125 | + | } | |
| 126 | + | .tooltip-wrapper:hover, | |
| 127 | + | .tooltip-wrapper:active { | |
| 128 | + | background: none !important; | |
| 129 | + | border: none !important; | |
| 130 | + | box-shadow: none !important; | |
| 131 | + | } | |
| 132 | + | .tooltip-wrapper:focus { | |
| 133 | + | outline: none !important; | |
| 134 | + | box-shadow: none !important; | |
| 135 | + | } | |
| 136 | + | .tooltip-wrapper:focus-visible { | |
| 137 | + | outline: 2px solid #003C74 !important; | |
| 138 | + | outline-offset: 2px !important; | |
| 99 | 139 | } | |
| 100 | 140 | /* Use :global for styles that need to apply to elements outside the component */ | |
| 101 | 141 | :global(.tooltip) { | |
| @@ -12,8 +12,8 @@ | |||||
| 12 | let text = $derived(data.text); | 12 | let text = $derived(data.text); | ||
| 13 | let direction = $derived(data.direction ?? 'top'); | 13 | let direction = $derived(data.direction ?? 'top'); | ||
| 14 | 14 | ||||
| 15 | - | let wrapper: HTMLDivElement; | 15 | + | let wrapper: HTMLButtonElement; |
| 16 | - | let tooltipDiv: HTMLDivElement | null = null; | 16 | + | let tooltipDiv = $state<HTMLDivElement | null>(null); |
| 17 | let coords = { x: 0, y: 0 }; | 17 | let coords = { x: 0, y: 0 }; | ||
| 18 | 18 | ||||
| 19 | function updateCoords() { | 19 | function updateCoords() { | ||
| @@ -33,11 +33,14 @@ | |||||
| 33 | 33 | ||||
| 34 | function showTooltip() { | 34 | function showTooltip() { | ||
| 35 | updateCoords(); | 35 | updateCoords(); | ||
| 36 | + | if (tooltipDiv) return; // Prevent multiple tooltips | |||
| 36 | tooltipDiv = document.createElement('div'); | 37 | tooltipDiv = document.createElement('div'); | ||
| 37 | tooltipDiv.className = `tooltip ${direction}`; | 38 | tooltipDiv.className = `tooltip ${direction}`; | ||
| 38 | tooltipDiv.style.position = 'fixed'; | 39 | tooltipDiv.style.position = 'fixed'; | ||
| 39 | tooltipDiv.style.zIndex = '1000000'; | 40 | tooltipDiv.style.zIndex = '1000000'; | ||
| 40 | tooltipDiv.textContent = text; | 41 | tooltipDiv.textContent = text; | ||
| 42 | + | tooltipDiv.setAttribute('role', 'tooltip'); | |||
| 43 | + | tooltipDiv.setAttribute('aria-hidden', 'false'); | |||
| 41 | document.body.appendChild(tooltipDiv); | 44 | document.body.appendChild(tooltipDiv); | ||
| 42 | 45 | ||||
| 43 | const tooltipRect = tooltipDiv.getBoundingClientRect(); | 46 | const tooltipRect = tooltipDiv.getBoundingClientRect(); | ||
| @@ -78,24 +81,61 @@ | |||||
| 78 | tooltipDiv = null; | 81 | tooltipDiv = null; | ||
| 79 | } | 82 | } | ||
| 80 | } | 83 | } | ||
| 84 | + | ||||
| 85 | + | function handleKeyDown(e: KeyboardEvent) { | |||
| 86 | + | if (e.key === 'Escape') { | |||
| 87 | + | hideTooltip(); | |||
| 88 | + | } | |||
| 89 | + | } | |||
| 81 | </script> | 90 | </script> | ||
| 82 | 91 | ||||
| 83 | - | <div | 92 | + | <button |
| 84 | class="tooltip-wrapper" | 93 | class="tooltip-wrapper" | ||
| 85 | bind:this={wrapper} | 94 | bind:this={wrapper} | ||
| 86 | onmouseover={showTooltip} | 95 | onmouseover={showTooltip} | ||
| 87 | onmouseout={hideTooltip} | 96 | onmouseout={hideTooltip} | ||
| 88 | onfocus={showTooltip} | 97 | onfocus={showTooltip} | ||
| 89 | onblur={hideTooltip} | 98 | onblur={hideTooltip} | ||
| 90 | - | role="tooltip" | 99 | + | onkeydown={handleKeyDown} |
| 91 | - | tabindex="-1" | 100 | + | type="button" |
| 101 | + | aria-describedby={tooltipDiv?.id} | |||
| 92 | > | 102 | > | ||
| 93 | {@render children()} | 103 | {@render children()} | ||
| 94 | - | </div> | 104 | + | </button> |
| 95 | 105 | ||||
| 96 | <style> | 106 | <style> | ||
| 97 | .tooltip-wrapper { | 107 | .tooltip-wrapper { | ||
| 98 | - | display: inline-block; | 108 | + | display: inline; |
| 109 | + | width: auto; | |||
| 110 | + | min-width: 0; | |||
| 111 | + | background: none !important; | |||
| 112 | + | border: none !important; | |||
| 113 | + | padding: 0 !important; | |||
| 114 | + | margin: 0 !important; | |||
| 115 | + | font: inherit !important; | |||
| 116 | + | color: inherit !important; | |||
| 117 | + | cursor: inherit !important; | |||
| 118 | + | text-align: inherit !important; | |||
| 119 | + | box-shadow: none !important; | |||
| 120 | + | text-shadow: inherit !important; | |||
| 121 | + | border-radius: 0 !important; | |||
| 122 | + | -webkit-appearance: none !important; | |||
| 123 | + | appearance: none !important; | |||
| 124 | + | white-space: pre; | |||
| 125 | + | } | |||
| 126 | + | .tooltip-wrapper:hover, | |||
| 127 | + | .tooltip-wrapper:active { | |||
| 128 | + | background: none !important; | |||
| 129 | + | border: none !important; | |||
| 130 | + | box-shadow: none !important; | |||
| 131 | + | } | |||
| 132 | + | .tooltip-wrapper:focus { | |||
| 133 | + | outline: none !important; | |||
| 134 | + | box-shadow: none !important; | |||
| 135 | + | } | |||
| 136 | + | .tooltip-wrapper:focus-visible { | |||
| 137 | + | outline: 2px solid #003C74 !important; | |||
| 138 | + | outline-offset: 2px !important; | |||
| 99 | } | 139 | } | ||
| 100 | /* Use :global for styles that need to apply to elements outside the component */ | 140 | /* Use :global for styles that need to apply to elements outside the component */ | ||
| 101 | :global(.tooltip) { | 141 | :global(.tooltip) { | ||