diff --git a/design-system/css/components/tooltip.css b/design-system/css/components/tooltip.css new file mode 100644 index 00000000..164571cc --- /dev/null +++ b/design-system/css/components/tooltip.css @@ -0,0 +1,82 @@ +/* + * AlteredCore design system — tooltip and masked value. + * + * ac-tooltip: a short text shown next to its host on hover (devices that can hover), and on focus (keyboard, or a tap + * on touch screens, where the host takes focus because it has tabindex="0"). + * ac-masked: a value shown partly hidden (an e-mail address), the full value in a tooltip. + */ + +.ac-tooltip-host { + position: relative; +} + +.ac-tooltip { + position: absolute; + left: 50%; + bottom: calc(100% + var(--ac-space-2)); + z-index: var(--ac-z-overlay); + transform: translateX(-50%); + padding: var(--ac-space-1-5) var(--ac-space-2-5); + border-radius: var(--ac-radius-sm); + background: var(--ac-color-inverse); + color: var(--ac-color-on-inverse); + box-shadow: var(--ac-shadow-e2); + font: var(--ac-font-small); + font-weight: 400; + white-space: nowrap; + pointer-events: none; + opacity: 0; + visibility: hidden; + transition: opacity var(--ac-duration-fast), visibility var(--ac-duration-fast); +} + +/* Arrow pointing at the host. */ +.ac-tooltip::after { + content: ''; + position: absolute; + top: 100%; + left: 50%; + transform: translateX(-50%); + border: var(--ac-space-1-5) solid transparent; + border-top-color: var(--ac-color-inverse); +} + +/* Aligned on the host's start edge instead of centred: a host near the left edge of a narrow box. */ +.ac-tooltip--start { + left: 0; + transform: none; +} +.ac-tooltip--start::after { + left: var(--ac-space-3); +} + +/* Under the host: a host at the top of a box that clips its content (a scrolling menu). */ +.ac-tooltip--below { + top: calc(100% + var(--ac-space-2)); + bottom: auto; +} +.ac-tooltip--below::after { + top: auto; + bottom: 100%; + border-top-color: transparent; + border-bottom-color: var(--ac-color-inverse); +} + +.ac-tooltip-host:focus .ac-tooltip, +.ac-tooltip-host:focus-within .ac-tooltip { + opacity: 1; + visibility: visible; +} + +@media (hover: hover) { + .ac-tooltip-host:hover .ac-tooltip { + opacity: 1; + visibility: visible; + } +} + +.ac-masked { + display: inline-block; + border-bottom: 1px dashed var(--ac-color-border-dashed); + cursor: help; +} diff --git a/design-system/docs/components/README.md b/design-system/docs/components/README.md index 5278d262..e05f240a 100644 --- a/design-system/docs/components/README.md +++ b/design-system/docs/components/README.md @@ -16,6 +16,7 @@ One page per component family. Each page matches one file of `css/components/` ( | [drawer.md](drawer.md) | `drawer.css` | Drawer handle and tab: hide and show a side panel | | [navigation.md](navigation.md) | `navigation.css` | Tabs, breadcrumb, pagination, menu, contents (`ac-toc`) | | [feedback.md](feedback.md) | `feedback.css` | Notice, toast, scrim, dialog, spinner, blocking loader, progress bar, table | +| [tooltip.md](tooltip.md) | `tooltip.css` | Tooltip, masked value (e-mail address shown partly hidden); `ac_masked_email()` | | [bootstrap-bridge.md](bootstrap-bridge.md) | `bridges/bootstrap.css` | Which Bootstrap classes are restyled, and when to use them | Tokens (colours, type scale, spacing, radii, shadows, density, layout, z-index): diff --git a/design-system/docs/components/tooltip.md b/design-system/docs/components/tooltip.md new file mode 100644 index 00000000..81132d65 --- /dev/null +++ b/design-system/docs/components/tooltip.md @@ -0,0 +1,64 @@ +# Tooltip and masked value + +`css/components/tooltip.css`. + +A tooltip shows a short text next to an element when the pointer is over it or when it has focus. +A masked value shows an e-mail address partly hidden (`c•••@gmail.com`), the full address in a +tooltip: anyone watching the screen (a stream, a video) cannot read it, the user can by pointing at +it. + +Do not put anything the user needs to act on in a tooltip (it cannot be reached on every device); +do not use a tooltip to label an icon-only button: use `aria-label` (and `title`, see +[button.md](button.md)). + +## Masked e-mail — `ac_masked_email()` + +Every e-mail address of the signed-in user shown on a page goes through it (account menu, account +page, newsletter). + +```php +

Vous recevez la newsletter à l'adresse .

+ +``` + +It renders: + +```html +c•••@gmail.comcamille.durand@gmail.com +``` + +`dsMaskEmail($email)` returns the masked text alone: the first character of the local part, `•••`, +then `@` and the domain; anything that is not an address gives `•••`. + +## Tooltip — `ac-tooltip` + +Put the tooltip inside its host. The host gets `ac-tooltip-host` and, when it is not a control +already, `tabindex="0"` so that the keyboard and a tap on a touch screen can show it. + +```php + + 3 + Main cost, reserve cost in brackets + +``` + +| Class | Effect | +|---|---| +| `ac-tooltip-host` | `position: relative`; shows its `ac-tooltip` on hover (devices that can hover) and on focus (`:focus`, `:focus-within`). | +| `ac-tooltip` | Inverse bubble, small text on one line, centred above the host, with an arrow. Hidden otherwise. | +| `ac-tooltip--start` | Aligned on the host's start edge instead of centred (a host near the edge of a narrow box). | +| `ac-tooltip--below` | Under the host (a host at the top of a box that clips or scrolls, like a menu). | +| `ac-masked` | Dashed underline and a help cursor: says that there is more to see. Used with `ac-tooltip-host`. | + +## Accessibility + +- `role="tooltip"` on the bubble, `aria-describedby` from the host to it: screen readers read the + full value as the host's description. +- The host is focusable (`tabindex="0"`), so the tooltip shows on keyboard focus, and on touch + screens after a tap (no hover there). It hides when focus leaves. +- Contrast: `--ac-color-on-inverse` on `--ac-color-inverse` is ≥ 4.5:1 in both themes. + +## Frameworks + +- **Angular / React**: render the same markup; give each bubble a unique id for + `aria-describedby`. diff --git a/design-system/php/ui.php b/design-system/php/ui.php index ba9970fb..c6b6fe57 100644 --- a/design-system/php/ui.php +++ b/design-system/php/ui.php @@ -131,6 +131,29 @@ function dsResolveIcon(string $ref, ?array &$extraClasses = null): ?string { * The Altered glyph font (fak fa-collection…) is not an SVG icon: those references are * rendered as unchanged. Unknown names render nothing. */ +/** + * E-mail address with the local part hidden after its first character: "camille@gmail.com" → "c•••@gmail.com". + * Anything that is not an address becomes "•••". + */ +function dsMaskEmail(string $email): string { + $at = strrpos($email, '@'); + if ($at === false || $at === 0 || $at === strlen($email) - 1) return '•••'; + return mb_substr($email, 0, 1, 'UTF-8') . '•••' . substr($email, $at); +} + +/** + * Masked e-mail address (ac-masked) with the full address in a tooltip, shown on hover or focus: an address on screen + * must not be readable by whoever watches it (a stream, a video), only by the user who points at it. + * $tooltipClass: ac-tooltip variants ('ac-tooltip--start', 'ac-tooltip--below'). + */ +function ac_masked_email(string $email, string $tooltipClass = ''): string { + static $n = 0; + $id = 'ac-masked-' . (++$n); + $e = function (string $s): string { return htmlspecialchars($s, ENT_QUOTES, 'UTF-8'); }; + return '' . $e(dsMaskEmail($email)) + . '' . $e($email) . ''; +} + function ac_icon(string $ref, string $class = '', ?string $label = null): string { if (preg_match('/(^|\s)(fak|fa-kit)(\s|$)/', $ref)) { return ''; diff --git a/includes/newsletter_sub.php b/includes/newsletter_sub.php index 173db96c..06265287 100644 --- a/includes/newsletter_sub.php +++ b/includes/newsletter_sub.php @@ -20,7 +20,9 @@ if (!csrfValid($_POST['csrf_token'] ?? '')) { flash('Invalid form token. Please try again.', 'error'); } else { - $nlEmail = trim($_POST['nl_email'] ?? ''); + // « Utiliser l'adresse de mon compte »: the address comes from the session, never from the page, so the page + // never has to show it. + $nlEmail = !empty($_POST['nl_use_account']) ? newsletterAccountEmail() : trim($_POST['nl_email'] ?? ''); if (!filter_var($nlEmail, FILTER_VALIDATE_EMAIL)) { flash($nlLang === 'fr' ? 'Veuillez saisir une adresse e-mail valide.' @@ -59,6 +61,14 @@ redirect(BASE_URL . '/'); } +/** E-mail address of the signed-in account (Keycloak or local), '' when signed out. */ +function newsletterAccountEmail(): string +{ + if (!empty($_SESSION['kc_logged_in'])) return (string)($_SESSION['kc_email'] ?? ''); + if (!empty($_SESSION['local_logged_in'])) return (string)($_SESSION['local_email'] ?? ''); + return ''; +} + function renderNewsletterBlock(): void { $txt = [ @@ -68,6 +78,7 @@ function renderNewsletterBlock(): void 'placeholder' => 'Your email address', 'btn' => 'Subscribe', 'privacy' => 'Your email is only used to send you news. No spam, unsubscribe anytime.', + 'use_account' => 'Use my account address', ], 'fr' => [ 'title' => 'Restez informé', @@ -75,15 +86,12 @@ function renderNewsletterBlock(): void 'placeholder' => 'Votre adresse e-mail', 'btn' => 'S\'abonner', 'privacy' => 'Votre e-mail est uniquement utilisé pour vous envoyer les actualités. Pas de spam, désinscription à tout moment.', + 'use_account' => 'Utiliser l\'adresse de mon compte', ], ][getUiLang()] ?? []; - $email = ''; - if (!empty($_SESSION['kc_logged_in'])) { - $email = $_SESSION['kc_email'] ?? ''; - } elseif (!empty($_SESSION['local_logged_in'])) { - $email = $_SESSION['local_email'] ?? ''; - } + // The field is never filled with the account's address: someone filming or streaming the homepage would show it. + $hasAccountEmail = filter_var(newsletterAccountEmail(), FILTER_VALIDATE_EMAIL) !== false; ?>
@@ -100,13 +108,19 @@ function renderNewsletterBlock(): void
-
+ +
+ +
+

diff --git a/pages/account.php b/pages/account.php index d37e60f5..40e18945 100644 --- a/pages/account.php +++ b/pages/account.php @@ -170,7 +170,7 @@

-
+
@@ -250,7 +250,7 @@ class="btn btn-outline-secondary btn-sm">

-

' . h($accountEmail) . '') : $txt['newsletter_off'] ?>

+

' . ac_masked_email($accountEmail) . '') : $txt['newsletter_off'] ?>

diff --git a/pages/design-system.php b/pages/design-system.php index 8ff0ac1c..802414f0 100644 --- a/pages/design-system.php +++ b/pages/design-system.php @@ -555,6 +555,17 @@
+
+

Tooltip, masked value

+
+

Signed in as : point at the address, or focus it (ac_masked_email() renders span.ac-masked.ac-tooltip-host).

+
+ Start-aligned Aligned on the start edge + Below Under its host +
+
+
+

Icons — Lucide

PHP ac_icon('name'), site JS acIcon('name'), Angular <ac-icon name="…">. All names: lucide.dev/icons.

diff --git a/plugins/hello-world/pages/hellopagelock.php b/plugins/hello-world/pages/hellopagelock.php index 87bd4377..eb268fdc 100644 --- a/plugins/hello-world/pages/hellopagelock.php +++ b/plugins/hello-world/pages/hellopagelock.php @@ -81,7 +81,7 @@ - + diff --git a/tests/DesignSystemTest.php b/tests/DesignSystemTest.php index 6a2fb473..8334110f 100644 --- a/tests/DesignSystemTest.php +++ b/tests/DesignSystemTest.php @@ -225,3 +225,17 @@ function dsStrayHovers(string $path, string $root): array { assertSame(true, dsToken('--ac-color-primary', 'dark') !== null && dsToken('--ac-color-primary', 'dark') !== dsToken('--ac-color-primary'), "dsToken() reads the dark theme block"); assertSame('#1050aa', dsToken('--ac-header-border'), "dsToken() resolves a var() value (light header border = primary-strong)"); assertSame(dsToken('--ac-color-primary-strong', 'dark'), dsToken('--ac-color-link', 'dark'), "dsToken() resolves a var() value in the dark theme"); + +// ---- Masked e-mail address (tooltip.css) ---- +assertSame('c•••@gmail.com', dsMaskEmail('camille.durand@gmail.com'), 'dsMaskEmail keeps the first character and the domain'); +assertSame('é•••@exemple.fr', dsMaskEmail('élodie@exemple.fr'), 'dsMaskEmail keeps a multibyte first character whole'); +foreach (['', 'camille', '@gmail.com', 'camille@'] as $notAnAddress) { + assertSame('•••', dsMaskEmail($notAnAddress), 'dsMaskEmail hides ' . var_export($notAnAddress, true) . ' entirely'); +} +$masked = ac_masked_email('a@x.fr', 'ac-tooltip--below'); +assertSame(true, strpos($masked, 'a•••@x.fr') === false, 'ac_masked_email escapes the address'); + +// ---- The newsletter field is never filled with the account's address ---- +$nl = (string)file_get_contents($root . '/includes/newsletter_sub.php'); +assertSame(0, preg_match('/name="nl_email"[^>]*value=/s', $nl), 'the homepage newsletter field has no value'); diff --git a/tests/e2e/newsletter-email.spec.ts b/tests/e2e/newsletter-email.spec.ts new file mode 100644 index 00000000..9feef835 --- /dev/null +++ b/tests/e2e/newsletter-email.spec.ts @@ -0,0 +1,48 @@ +import { test, expect, login, evidence } from './fixtures'; + +// The signed-in user's e-mail address is never readable on screen (a creator filming the site leaked theirs through +// the prefilled newsletter field): masked, the full address only in a tooltip on hover or focus. + +const EMAIL = 'alice@example.test'; + +/** Text of the page without the tooltips: what a viewer of the screen can read. */ +async function visibleText(page: import('./fixtures').Page): Promise { + return page.evaluate(() => { + const body = document.body.cloneNode(true) as HTMLElement; + body.querySelectorAll('.ac-tooltip, script, style').forEach((el) => el.remove()); + return body.textContent ?? ''; + }); +} + +test('the homepage newsletter field is empty, and the account address subscribes without being shown', async ({ page }, testInfo) => { + await login(page, 'alice', '/'); + const field = page.locator('input[name="nl_email"]'); + await expect(field).toHaveValue(''); + await field.scrollIntoViewIfNeeded(); + await evidence(page, testInfo, 'home-newsletter'); + expect(await visibleText(page)).not.toContain(EMAIL); + + await page.locator('button[name="nl_use_account"]').click(); + await expect(page.getByRole('alert')).toContainText(/Subscription confirmed|already subscribed|Inscription confirmée|déjà inscrite/); +}); + +test('the account menu and page show the address masked, in full in a tooltip', async ({ page }, testInfo) => { + await login(page, 'alice', '/pages/account'); + expect(await visibleText(page)).not.toContain(EMAIL); + + const masked = page.locator('.ac-masked').filter({ visible: true }).first(); + await expect(masked).toContainText('a•••@example.test'); + const tooltip = masked.locator('.ac-tooltip'); + await expect(tooltip).toBeHidden(); + await masked.focus(); + await expect(tooltip).toBeVisible(); + await expect(tooltip).toHaveText(EMAIL); + await evidence(page, testInfo, 'account-page'); + + await page.locator('#azAccountBtn').click(); + const menuMasked = page.locator('#azAccountMenu .ac-masked'); + await expect(menuMasked).toContainText('a•••@example.test'); + await menuMasked.focus(); + await expect(menuMasked.locator('.ac-tooltip')).toBeVisible(); + await evidence(page, testInfo, 'account-menu'); +}); diff --git a/themes/azure/nav.php b/themes/azure/nav.php index 0141d48a..257346d6 100644 --- a/themes/azure/nav.php +++ b/themes/azure/nav.php @@ -221,7 +221,7 @@ class="nav-link "
- +