Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions design-system/css/components/tooltip.css
Original file line number Diff line number Diff line change
@@ -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;
}
1 change: 1 addition & 0 deletions design-system/docs/components/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
64 changes: 64 additions & 0 deletions design-system/docs/components/tooltip.md
Original file line number Diff line number Diff line change
@@ -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
<p>Vous recevez la newsletter à l'adresse <?= ac_masked_email($email) ?>.</p>
<span class="az-am-sub"><?= ac_masked_email($user['email'], 'ac-tooltip--start ac-tooltip--below') ?></span>
```

It renders:

```html
<span class="ac-masked ac-tooltip-host" tabindex="0" aria-describedby="ac-masked-1">c•••@gmail.com<span class="ac-tooltip" role="tooltip" id="ac-masked-1">camille.durand@gmail.com</span></span>
```

`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
<span class="ac-tooltip-host" tabindex="0" aria-describedby="tip-cost">
3 <?= ac_icon('info', 'ac-icon--16') ?>
<span class="ac-tooltip" role="tooltip" id="tip-cost">Main cost, reserve cost in brackets</span>
</span>
```

| 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`.
23 changes: 23 additions & 0 deletions design-system/php/ui.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 <i class="…"> 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 '<span class="ac-masked ac-tooltip-host" tabindex="0" aria-describedby="' . $id . '">' . $e(dsMaskEmail($email))
. '<span class="' . $e(trim('ac-tooltip ' . $tooltipClass)) . '" role="tooltip" id="' . $id . '">' . $e($email) . '</span></span>';
}

function ac_icon(string $ref, string $class = '', ?string $label = null): string {
if (preg_match('/(^|\s)(fak|fa-kit)(\s|$)/', $ref)) {
return '<i class="' . htmlspecialchars(trim($ref . ' ' . $class), ENT_QUOTES, 'UTF-8') . '" aria-hidden="true"></i>';
Expand Down
32 changes: 23 additions & 9 deletions includes/newsletter_sub.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.'
Expand Down Expand Up @@ -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 = [
Expand All @@ -68,22 +78,20 @@ 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é',
'desc' => 'Abonnez-vous à la newsletter et recevez les dernières actualités d\'Altered Re:Union directement dans votre boîte mail.',
'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;
?>
<section class="container py-4">
<div class="card-altered p-4">
Expand All @@ -100,13 +108,19 @@ function renderNewsletterBlock(): void
<input type="hidden" name="csrf_token" value="<?= h(csrfToken()) ?>">
<input type="hidden" name="nl_subscribe" value="1">
<div class="flex-grow-1" style="min-width:220px">
<input type="email" name="nl_email" class="form-control"
value="<?= h($email) ?>"
<input type="email" name="nl_email" class="form-control" autocomplete="off"
aria-label="<?= h($txt['placeholder']) ?>"
placeholder="<?= h($txt['placeholder']) ?>" required>
</div>
<button type="submit" class="btn btn-sm btn-primary-altered px-4">
<i class="fa-solid fa-paper-plane me-1"></i> <?= $txt['btn'] ?>
</button>
<?php if ($hasAccountEmail): ?>
<div class="w-100">
<button type="submit" name="nl_use_account" value="1"
class="ac-button ac-button--ghost ac-button--sm"><?= h($txt['use_account']) ?></button>
</div>
<?php endif; ?>
</form>
<p class="text-muted mt-2 mb-0" style="font-size:.8rem">
<i class="fa-solid fa-lock me-1"></i><?= $txt['privacy'] ?>
Expand Down
4 changes: 2 additions & 2 deletions pages/account.php
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@
</div>
<div>
<div style="font-weight:800;font-size:1.25rem"><?= h($userRow['username'] ?? $kcU['username']) ?></div>
<div style="font-size:.85rem;color:var(--neutral-500)"><?= h($userRow['email'] ?? $kcU['email']) ?></div>
<div style="font-size:.85rem;color:var(--neutral-500)"><?= ac_masked_email((string)($userRow['email'] ?? $kcU['email'])) ?></div>
</div>
</div>
<dl class="row mb-0" style="font-size:.9rem">
Expand Down Expand Up @@ -250,7 +250,7 @@ class="btn btn-outline-secondary btn-sm">
<p class="small mb-0" style="color:var(--ac-color-text-muted)"><?= $txt['newsletter_blocked'] ?></p>
<?php else: ?>
<?php $nlSubscribed = !empty($newsletterSub['subscribed']); ?>
<p class="small mb-3"><?= $nlSubscribed ? sprintf($txt['newsletter_on'], '<strong>' . h($accountEmail) . '</strong>') : $txt['newsletter_off'] ?></p>
<p class="small mb-3"><?= $nlSubscribed ? sprintf($txt['newsletter_on'], '<strong>' . ac_masked_email($accountEmail) . '</strong>') : $txt['newsletter_off'] ?></p>
<form method="post">
<input type="hidden" name="csrf_token" value="<?= h(csrfToken()) ?>">
<?php if ($nlSubscribed): ?>
Expand Down
11 changes: 11 additions & 0 deletions pages/design-system.php
Original file line number Diff line number Diff line change
Expand Up @@ -555,6 +555,17 @@
</div>
</section>

<section class="ds-section" aria-labelledby="ds-tooltip">
<h2 class="ac-section-title" id="ds-tooltip">Tooltip, masked value</h2>
<div class="ac-stack">
<p>Signed in as <?= ac_masked_email('camille.durand@gmail.com') ?>: point at the address, or focus it (<code>ac_masked_email()</code> renders <code>span.ac-masked.ac-tooltip-host</code>).</p>
<div class="ac-row ac-row--lg">
<span class="ac-tooltip-host" tabindex="0" aria-describedby="ds-tip-start">Start-aligned <?= ac_icon('info', 'ac-icon--16') ?><span class="ac-tooltip ac-tooltip--start" role="tooltip" id="ds-tip-start">Aligned on the start edge</span></span>
<span class="ac-tooltip-host" tabindex="0" aria-describedby="ds-tip-below">Below <?= ac_icon('info', 'ac-icon--16') ?><span class="ac-tooltip ac-tooltip--below" role="tooltip" id="ds-tip-below">Under its host</span></span>
</div>
</div>
</section>

<section class="ds-section" aria-labelledby="ds-icons">
<h2 class="ac-section-title" id="ds-icons">Icons — Lucide</h2>
<p class="ac-text-muted">PHP <code>ac_icon('name')</code>, site JS <code>acIcon('name')</code>, Angular <code>&lt;ac-icon name="…"&gt;</code>. All names: <a href="https://lucide.dev/icons/" target="_blank" rel="noopener">lucide.dev/icons</a>.</p>
Expand Down
2 changes: 1 addition & 1 deletion plugins/hello-world/pages/hellopagelock.php
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@
</tr>
<tr>
<td><?= h($txt['mail_label']) ?></td>
<td><code><?= h($userMail) ?></code></td>
<td><code><?= ac_masked_email((string)$userMail) ?></code></td>
<td class="ac-text-small ac-text-muted"><?= h($txt['mail_note']) ?></td>
</tr>
</tbody>
Expand Down
14 changes: 14 additions & 0 deletions tests/DesignSystemTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<b>@x.fr', 'ac-tooltip--below');
assertSame(true, strpos($masked, 'a•••@x.fr<span class="ac-tooltip ac-tooltip--below" role="tooltip"') !== false, 'ac_masked_email shows the masked address, the full one in the tooltip');
assertSame(true, strpos($masked, 'a&lt;b&gt;@x.fr') !== false && strpos($masked, '<b>') === 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');
48 changes: 48 additions & 0 deletions tests/e2e/newsletter-email.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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');
});
2 changes: 1 addition & 1 deletion themes/azure/nav.php
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ class="nav-link <?= $__niActive ? 'active' : '' ?>"
<span class="ac-avatar az-am-avatar is-user" aria-hidden="true"><?= h(mb_strtoupper(mb_substr($__amUser['username'] ?? '', 0, 1))) ?></span>
<div class="az-am-who">
<span class="az-am-name"><?= h($__amUser['username'] ?? '') ?></span>
<?php if (!empty($__amUser['email'])): ?><span class="az-am-sub"><?= h($__amUser['email']) ?></span><?php endif; ?>
<?php if (!empty($__amUser['email'])): ?><span class="az-am-sub"><?= ac_masked_email($__amUser['email'], 'ac-tooltip--start ac-tooltip--below') ?></span><?php endif; ?>
</div>
</div>
<div class="az-am-links">
Expand Down
Loading
Loading