fix: language buttons real design / conformance

This commit is contained in:
fsylvain committed 2026-10-04 17:08:43 +02:00
1 parent 2c8f39273c
commit 0490a4c494
23 files changed
+1408 -412

No files matched your search

+16 -6
View File
@@ -47,7 +47,7 @@ These are the RenewCMS components, built from the primitives on the first page .
### AuthShell
One centered card for every public screen. The logo sits above the title, and the form or ResultPanel goes in the slot. Footer links and the theme select sit outside the card, so the card itself stays about one task.
One centered card for every public screen. The logo sits above the title, and the form or ResultPanel goes in the slot. Footer links, the language chip and the theme select sit outside the card, so the card itself stays about one task.
Log in Welcome back. Log in to manage your articles.
@@ -55,7 +55,7 @@ default slot
Forgot password? System
Built from Custom layout component AuthShell.vue : a UPageCard variant="outline" inside a centered main , with AppLogo , and UColorModeSelect size="sm" in the footer. Forms inside it use UForm rather than UAuthForm , because every screen needs states (such as 403, 429 and an inline login during verification) that UAuthForm doesn't expose.
Built from Custom layout component AuthShell.vue : a UPageCard variant="outline" inside a centered main , with AppLogo , and UColorModeSelect size="sm" in the footer, next to the language chip (see LanguageSwitcher). Forms inside it use UForm rather than UAuthForm , because every screen needs states (such as 403, 429 and an inline login during verification) that UAuthForm doesn't expose.
Props, slots Props title: string , description?: string . Slots default , notice (an alert above the title, for session expired or password changed), footer (links). No emits.
@@ -383,12 +383,13 @@ The Product components board was not regenerated for these. They are written fro
### UserMenu (changed)
The chip at the foot of the sidebar no longer logs out on click. It opens the account menu, which lists Profile, the theme choice and Log out.
The chip at the foot of the sidebar no longer logs out on click. It opens the account menu, which lists Profile, the theme choice, the language choice and Log out.
- **Chip:** 40 px high, an initials avatar (26 px, sand `#d4c5b0` with ink text), the name, and a chevrons-up-down icon. `aria-haspopup="menu"`, `aria-expanded`, title "Account". While the menu is open, or while `/profile` is the current page, it takes `--rc-sand` with `#111111` text.
- **Name:** the display name when there is one, otherwise the username. Initials come from the first letters of the first two words of the display name, uppercased. Without a display name they come from the username.
- **Desktop menu:** a 224 px popover anchored above the chip on its left edge, with `--rc-shadow-pop`. Header: the name in semibold, the email below it in muted 12 px, a bottom border. Items are 32 px: Profile (circle-user), a separator, System, Light and Dark (14 px text, a check icon at the right edge on the current choice), a separator, Log out (log-out). The current page's item is highlighted with `--ui-bg-elevated`. The theme items are radio-style: choosing one applies at once and keeps the menu open. Nuxt UI: `type: 'checkbox'` items bound to `useColorMode().preference`.
- **Mobile:** the account row in the navigation drawer opens a bottom sheet over an `--rc-overlay` scrim, with a grab handle. Header: 36 px avatar, name in 15 px semibold, email in muted text. Items are 48 px at 15 px text; the theme items keep the same order and separators.
- **Desktop menu:** a 224 px popover anchored above the chip on its left edge, with `--rc-shadow-pop`. Header: the name in semibold, the email below it in muted 12 px, a bottom border. Items are 32 px: Profile (circle-user), a separator, a "Theme" section label, System, Light and Dark (14 px text, a check icon at the right edge on the current choice), a separator, a "Language" section label, English and Français (same row as the theme items, a check icon on the current one), a separator, Log out (log-out). Section labels are 11 px, weight 500, uppercase, letter-spacing .04em, `--ui-text-muted`, padded 6px 8px 2px. The current page's item is highlighted with `--ui-bg-elevated`. The theme items are radio-style: choosing one applies at once and keeps the menu open. Nuxt UI: `type: 'checkbox'` items bound to `useColorMode().preference`.
- **Mobile:** the account row in the navigation drawer opens a bottom sheet over an `--rc-overlay` scrim, with a grab handle. Header: 36 px avatar, name in 15 px semibold, email in muted text. Items are 48 px at 15 px text; the theme and language items keep the same order, section labels (padded 8px 16px 2px) and separators.
- **Language items:** a `menuitemradio` group named by the "Language" label. Each item is labelled with the language's own name (English, Français) and carries `lang="en"` / `lang="fr"`. Choosing one applies the language at once and keeps the menu open, like the theme items. See LanguageSwitcher.
- **Keyboard:** Escape or a click outside closes it. Arrow keys move between items.
- The board now draws the theme items, so the chip, menu and sheet need no visual exception. The board highlights Profile even on Home; the app highlights it only on `/profile`.
@@ -734,7 +735,16 @@ Three choices: System (the default), Light and Dark. The toggle at the top of th
Nuxt UI In the user menu: a UDropdownMenu group of three type: 'checkbox' items bound to useColorMode().preference . On auth screens: UColorModeSelect in the footer. Nuxt UI's UColorModeButton isn't used because it only toggles between light and dark and hides the system option. colorMode: true in the plugin handles storage and applying the .dark class before first paint.
ARIA Menu items use menuitemcheckbox , with aria-checked on the current choice. The segmented variant is a radiogroup named "Theme".
ARIA Menu items use menuitemradio , with aria-checked on the current choice. The segmented variant is a radiogroup named "Theme".
### LanguageSwitcher
Two choices: English and Français. Each is always written in its own language, whatever the interface language. It appears in two places and never as a form select.
- **Account menu (signed in):** a "Language" section between the theme items and Log out. See UserMenu (changed).
- **Auth footer (signed out):** a chip beside the System theme chip, in the footer outside the card. The chip is 28 px high on desktop and 44 px on mobile, 8 px side padding, 1 px `--ui-border-accented` border, `--ui-text-toned` text, a 14 px lucide `languages` icon, then the current language's own name. `aria-haspopup="menu"`, `aria-expanded`, `aria-label` "Language: English". While open it takes `--ui-bg-elevated`. The footer sits at the right with an 8 px gap between the two chips.
- **Auth popover:** opens above the chip (bottom 36 px, right-aligned, 168 px wide), `--ui-bg`, 1 px `--ui-border`, `--rc-shadow-pop`, 4 px padding, `role="menu"` named "Language". Rows are 32 px (44 px on mobile), 14 px text, `menuitemradio` with `lang`, a 16 px check icon at the right edge on the current one. Choosing a row applies the language and closes the popover; Escape or an outside click closes it without changing anything.
- The choice persists across reloads and applies to every screen at once.
## Added with roles, invite actions and Settings
+5 -1
View File
@@ -48,8 +48,12 @@ Desktop: a 224 px popover above the chip, aligned to its left. Mobile: the accou
| Header | The name in semibold (display name, or the username), and the email in muted 12 px (14 px on mobile, with a 36 px initials avatar beside them) |
| Item | Profile (circle-user icon), highlighted while on `/profile` |
| Separator | |
| Label | "Theme": 11 px, weight 500, uppercase, letter-spacing .04em, muted |
| Items | System, Light, Dark: a radio-style group, the current choice carries a check icon at the right edge; choosing one applies the theme at once and keeps the menu open |
| Separator | |
| Label | "Language", styled like "Theme" |
| Items | English, Français (each in its own language, with `lang`): a radio-style group with a check on the current one; choosing one applies the language at once and keeps the menu open |
| Separator | |
| Item | Log out (log-out icon) |
Avatar initials come from the first letters of the first two words of the display name, uppercased ("Marta Alves" gives MA). Without a display name they come from the username.
@@ -64,7 +68,7 @@ The board's menu now carries System, Light and Dark, matching the shipped UserMe
- Breadcrumbs: Home (mobile title "Home")
- Page behind the menu: Home, default
- Menu open, as described above, with the System, Light and Dark group between Profile and Log out. The board shows Light checked
- Menu open, as described above, with the Theme group (System, Light, Dark) and the Language group (English, Français) between Profile and Log out. The board shows Light and English checked
### Loading
+3 -3
View File
@@ -18,7 +18,7 @@
<sc-if value="{{ s.boot }}"><div style="display:flex;align-items:center;gap:10px;height:40px;padding:0 6px"><span style="width:26px;height:26px;background:var(--ui-bg-accented)"></span><span style="width:80px;height:12px;background:var(--ui-bg-accented)"></span></div></sc-if>
<sc-if value="{{ notBoot }}"><div data-act="usermenu" role="button" aria-haspopup="menu" aria-expanded="{{ menuOpen }}" title="Account" style="display:flex;align-items:center;gap:10px;height:40px;padding:0 6px;background:{{ chipBg }};color:{{ chipFg }}"><span style="width:26px;height:26px;flex-shrink:0;background:#d4c5b0;color:#111;display:flex;align-items:center;justify-content:center;font:600 11px 'IBM Plex Sans',sans-serif">{{ ini }}</span><span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500">{{ who }}</span><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevrons-up-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevrons-up-down.svg') center / contain no-repeat"></span></div></sc-if>
</div>
<sc-if value="{{ deskMenu }}"><div role="menu" aria-label="Account" style="position:absolute;left:12px;bottom:56px;z-index:40;width:224px;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);padding:4px"><div style="padding:8px 8px 10px;display:flex;flex-direction:column;gap:2px;border-bottom:1px solid var(--ui-border);margin-bottom:4px"><span style="font-weight:600;color:var(--ui-text-highlighted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ who }}</span><span style="font-size:12px;color:var(--ui-text-muted)">marta@renew.example</span></div><div data-act="nav-profile" role="menuitem" style="height:32px;padding:0 8px;display:flex;align-items:center;gap:10px;background:{{ pfItemBg }};color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/circle-user.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/circle-user.svg') center / contain no-repeat"></span>Profile</div><div style="height:1px;background:var(--ui-border);margin:4px 0"></div><sc-for list="{{ themeSeg }}" as="m" hint-placeholder-count="3"><div data-act="{{ m.act }}" role="menuitemradio" aria-checked="{{ m.on }}" style="height:32px;padding:0 8px;display:flex;align-items:center;justify-content:space-between;font-size:14px;color:var(--ui-text-highlighted)">{{ m.t }}<span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;opacity:{{ m.op }};-webkit-mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat"></span></div></sc-for><div style="height:1px;background:var(--ui-border);margin:4px 0"></div><div data-act="logout" role="menuitem" style="height:32px;padding:0 8px;display:flex;align-items:center;gap:10px;color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/log-out.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/log-out.svg') center / contain no-repeat"></span>Log out</div></div></sc-if>
<sc-if value="{{ deskMenu }}"><div role="menu" aria-label="Account" style="position:absolute;left:12px;bottom:56px;z-index:40;width:224px;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);padding:4px"><div style="padding:8px 8px 10px;display:flex;flex-direction:column;gap:2px;border-bottom:1px solid var(--ui-border);margin-bottom:4px"><span style="font-weight:600;color:var(--ui-text-highlighted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ who }}</span><span style="font-size:12px;color:var(--ui-text-muted)">marta@renew.example</span></div><div data-act="nav-profile" role="menuitem" style="height:32px;padding:0 8px;display:flex;align-items:center;gap:10px;background:{{ pfItemBg }};color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/circle-user.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/circle-user.svg') center / contain no-repeat"></span>Profile</div><div style="height:1px;background:var(--ui-border);margin:4px 0"></div><div style="padding:6px 8px 2px;font-size:11px;font-weight:500;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Theme</div><sc-for list="{{ themeSeg }}" as="m" hint-placeholder-count="3"><div data-act="{{ m.act }}" role="menuitemradio" aria-checked="{{ m.on }}" style="height:32px;padding:0 8px;display:flex;align-items:center;justify-content:space-between;font-size:14px;color:var(--ui-text-highlighted)">{{ m.t }}<span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;opacity:{{ m.op }};-webkit-mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat"></span></div></sc-for><div style="height:1px;background:var(--ui-border);margin:4px 0"></div><div style="padding:6px 8px 2px;font-size:11px;font-weight:500;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Language</div><sc-for list="{{ langSeg }}" as="m" hint-placeholder-count="2"><div data-act="{{ m.act }}" lang="{{ m.code }}" role="menuitemradio" aria-checked="{{ m.on }}" style="height:32px;padding:0 8px;display:flex;align-items:center;justify-content:space-between;font-size:14px;color:var(--ui-text-highlighted)">{{ m.t }}<span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;opacity:{{ m.op }};-webkit-mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat"></span></div></sc-for><div style="height:1px;background:var(--ui-border);margin:4px 0"></div><div data-act="logout" role="menuitem" style="height:32px;padding:0 8px;display:flex;align-items:center;gap:10px;color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/log-out.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/log-out.svg') center / contain no-repeat"></span>Log out</div></div></sc-if>
</sc-if>
<div style="flex:1;min-width:0;min-height:0;display:flex;flex-direction:column">
<sc-if value="{{ desk }}" hint-placeholder-val="{{ true }}">
@@ -280,7 +280,7 @@
</div>
</div>
<sc-if value="{{ mobMenu }}"><div style="position:absolute;inset:0;z-index:50;background:var(--rc-overlay);display:flex;align-items:flex-end"><div role="menu" aria-label="Account" style="width:100%;background:var(--ui-bg);box-shadow:var(--rc-shadow-modal);padding:8px 0 24px"><div style="margin:0 auto 8px;width:36px;height:4px;background:var(--ui-bg-accented)"></div><div style="padding:8px 16px 14px;display:flex;align-items:center;gap:12px;border-bottom:1px solid var(--ui-border);margin-bottom:4px"><span style="width:36px;height:36px;flex-shrink:0;background:#d4c5b0;color:#111;display:flex;align-items:center;justify-content:center;font:600 13px 'IBM Plex Sans',sans-serif">{{ ini }}</span><div style="display:flex;flex-direction:column;min-width:0"><span style="font-weight:600;font-size:15px;color:var(--ui-text-highlighted)">{{ who }}</span><span style="color:var(--ui-text-muted)">marta@renew.example</span></div></div><div data-act="nav-profile" role="menuitem" style="height:48px;padding:0 16px;display:flex;align-items:center;gap:12px;font-size:15px;background:{{ pfItemBg }};color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:18px;height:18px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/circle-user.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/circle-user.svg') center / contain no-repeat"></span>Profile</div><div style="height:1px;background:var(--ui-border);margin:4px 0"></div><sc-for list="{{ themeSeg }}" as="m" hint-placeholder-count="3"><div data-act="{{ m.act }}" role="menuitemradio" aria-checked="{{ m.on }}" style="height:48px;padding:0 16px;display:flex;align-items:center;justify-content:space-between;font-size:15px;color:var(--ui-text-highlighted)">{{ m.t }}<span aria-hidden="true" style="display:inline-block;width:18px;height:18px;background-color:currentColor;opacity:{{ m.op }};-webkit-mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat"></span></div></sc-for><div style="height:1px;background:var(--ui-border);margin:4px 0"></div><div data-act="logout" role="menuitem" style="height:48px;padding:0 16px;display:flex;align-items:center;gap:12px;font-size:15px;color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:18px;height:18px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/log-out.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/log-out.svg') center / contain no-repeat"></span>Log out</div></div></div></sc-if>
<sc-if value="{{ mobMenu }}"><div style="position:absolute;inset:0;z-index:50;background:var(--rc-overlay);display:flex;align-items:flex-end"><div role="menu" aria-label="Account" style="width:100%;background:var(--ui-bg);box-shadow:var(--rc-shadow-modal);padding:8px 0 24px"><div style="margin:0 auto 8px;width:36px;height:4px;background:var(--ui-bg-accented)"></div><div style="padding:8px 16px 14px;display:flex;align-items:center;gap:12px;border-bottom:1px solid var(--ui-border);margin-bottom:4px"><span style="width:36px;height:36px;flex-shrink:0;background:#d4c5b0;color:#111;display:flex;align-items:center;justify-content:center;font:600 13px 'IBM Plex Sans',sans-serif">{{ ini }}</span><div style="display:flex;flex-direction:column;min-width:0"><span style="font-weight:600;font-size:15px;color:var(--ui-text-highlighted)">{{ who }}</span><span style="color:var(--ui-text-muted)">marta@renew.example</span></div></div><div data-act="nav-profile" role="menuitem" style="height:48px;padding:0 16px;display:flex;align-items:center;gap:12px;font-size:15px;background:{{ pfItemBg }};color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:18px;height:18px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/circle-user.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/circle-user.svg') center / contain no-repeat"></span>Profile</div><div style="height:1px;background:var(--ui-border);margin:4px 0"></div><div style="padding:8px 16px 2px;font-size:11px;font-weight:500;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Theme</div><sc-for list="{{ themeSeg }}" as="m" hint-placeholder-count="3"><div data-act="{{ m.act }}" role="menuitemradio" aria-checked="{{ m.on }}" style="height:48px;padding:0 16px;display:flex;align-items:center;justify-content:space-between;font-size:15px;color:var(--ui-text-highlighted)">{{ m.t }}<span aria-hidden="true" style="display:inline-block;width:18px;height:18px;background-color:currentColor;opacity:{{ m.op }};-webkit-mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat"></span></div></sc-for><div style="height:1px;background:var(--ui-border);margin:4px 0"></div><div style="padding:8px 16px 2px;font-size:11px;font-weight:500;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Language</div><sc-for list="{{ langSeg }}" as="m" hint-placeholder-count="2"><div data-act="{{ m.act }}" lang="{{ m.code }}" role="menuitemradio" aria-checked="{{ m.on }}" style="height:48px;padding:0 16px;display:flex;align-items:center;justify-content:space-between;font-size:15px;color:var(--ui-text-highlighted)">{{ m.t }}<span aria-hidden="true" style="display:inline-block;width:18px;height:18px;background-color:currentColor;opacity:{{ m.op }};-webkit-mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat"></span></div></sc-for><div style="height:1px;background:var(--ui-border);margin:4px 0"></div><div data-act="logout" role="menuitem" style="height:48px;padding:0 16px;display:flex;align-items:center;gap:12px;font-size:15px;color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:18px;height:18px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/log-out.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/log-out.svg') center / contain no-repeat"></span>Log out</div></div></div></sc-if>
<sc-if value="{{ s.overlay }}"><div role="status" aria-live="polite" style="position:absolute;inset:0;z-index:70;background:var(--ui-bg-muted);display:flex;align-items:center;justify-content:center;padding:24px"><div style="width:100%;max-width:440px;display:flex;flex-direction:column;gap:16px;align-items:flex-start"><svg width="26" height="26" viewBox="0 0 16 16" shape-rendering="crispEdges" aria-hidden="true"><rect width="16" height="16" fill="#111"></rect><path fill="#fff" fill-rule="evenodd" d="M4 3h6v1h1v1h1v3h-1v1h-1v1h1v1h1v2h-2v-2h-1v-1H6v3H4zm2 2v3h4V5z"></path></svg><div style="display:flex;flex-direction:column;gap:6px"><span style="font:600 22px/30px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ s.overlay.title }}</span><span style="font-size:14px;line-height:22px;color:var(--ui-text-toned);text-wrap:pretty">{{ s.overlay.text }}</span></div><div role="progressbar" aria-label="Restarting" style="width:100%;height:4px;background:var(--ui-bg-accented)"><div style="width:35%;height:4px;margin-left:{{ s.overlay.barL }};background:var(--ui-primary)"></div></div><span style="font-size:12px;color:var(--ui-text-muted)">{{ s.overlay.meta }}</span><sc-if value="{{ s.overlay.btn }}"><span role="button" style="height:{{ btnH }};padding:0 14px;display:flex;align-items:center;gap:6px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-highlighted);font-weight:500">{{ s.overlay.btn }}</span></sc-if></div></div></sc-if>
@@ -435,7 +435,7 @@ class Component extends DCLogic {
const edTitle = e.title || 'How we price a bathroom renovation'
return {
cr, s, nav, crumbs, who, ini, pf, dn, dnSave, rsNote, rsBtn,
themeSeg: [['system','monitor','System'],['light','sun','Light'],['dark','moon','Dark']].map(([k,icon,tt])=>{const on=(s.mode||'light')===k;return {act:'theme-'+k,icon,t:tt,on,op:on?1:0}}), menuOpen: !!s.userMenu, deskMenu: !m && !!s.userMenu, mobMenu: m && !!s.userMenu, pfItemBg: 'var(--ui-bg-elevated)',
langSeg: [['en','English'],['fr','Français']].map(([k,tt])=>{const on=(s.lang||'en')===k;return {act:'lang-'+k,code:k,t:tt,on,op:on?1:0}}), themeSeg: [['system','monitor','System'],['light','sun','Light'],['dark','moon','Dark']].map(([k,icon,tt])=>{const on=(s.mode||'light')===k;return {act:'theme-'+k,icon,t:tt,on,op:on?1:0}}), menuOpen: !!s.userMenu, deskMenu: !m && !!s.userMenu, mobMenu: m && !!s.userMenu, pfItemBg: 'var(--ui-bg-elevated)',
chipBg: s.userMenu || s.nav === 'profile' ? 'var(--rc-sand)' : 'transparent', chipFg: s.userMenu || s.nav === 'profile' ? '#111111' : 'var(--ui-text-highlighted)',
isSettings: k === 'settings', isProfile: k === 'profile', pfLoading: k === 'profile' && !!pf.loading, pfReady: k === 'profile' && !pf.loading,
upBtnDir: m ? 'column' : 'row', upBtnAlign: m ? 'stretch' : 'flex-start', cardPad: m ? '16px' : '20px', cardPadX: m ? '16px' : '20px', inH: m ? '44px' : '40px', pfCols: m ? 'minmax(0,1fr)' : 'minmax(0,1fr) minmax(0,1.4fr)', pwDir: m ? 'column' : 'row', pwAlign: m ? 'stretch' : 'flex-end', edTitle, edBody: e.body || '', sampleBody: !e.body, tabs, rows, save, counter, pub, mFields, up, toasts, endpoints, toolbar,
+6 -3
View File
@@ -54,16 +54,17 @@
<sc-if value="{{ hasFooter }}">
<div style="width:{{ w }};max-width:100%;padding:{{ footPad }};display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;font-size:13px;color:var(--ui-text-muted)">
<span style="display:flex;gap:6px;flex-wrap:wrap;align-items:center"><sc-if value="{{ s.footer }}"><span>{{ s.footer.pre }}</span><span data-act="footer" style="color:var(--ui-text-highlighted);font-weight:500;text-decoration:underline;text-underline-offset:3px">{{ s.footer.link }}</span></sc-if><sc-if value="{{ s.pending }}"><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;border:1px dashed var(--ui-border-accented);color:var(--ui-text-toned);font:500 11px 'IBM Plex Mono',monospace">API pending</span></sc-if></span>
<span style="height:28px;padding:0 8px;display:flex;align-items:center;gap:6px;border:1px solid var(--ui-border-accented);color:var(--ui-text-toned)"><span aria-hidden="true" style="display:inline-block;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/monitor.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/monitor.svg') center / contain no-repeat"></span>System</span>
<span style="position:relative;display:flex;align-items:center;gap:8px"><sc-if value="{{ langOpen }}"><div role="menu" aria-label="Language" style="position:absolute;right:0;bottom:36px;z-index:10;width:168px;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);padding:4px"><sc-for list="{{ langSeg }}" as="m" hint-placeholder-count="2"><div data-act="{{ m.act }}" lang="{{ m.code }}" role="menuitemradio" aria-checked="{{ m.on }}" style="height:{{ optH }};padding:0 8px;display:flex;align-items:center;justify-content:space-between;font-size:14px;color:var(--ui-text-highlighted)">{{ m.t }}<span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;opacity:{{ m.op }};-webkit-mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat"></span></div></sc-for></div></sc-if><span data-act="langmenu" role="button" aria-haspopup="menu" aria-expanded="{{ langOpen }}" aria-label="Language: {{ langName }}" style="height:{{ chipH }};padding:0 8px;display:flex;align-items:center;gap:6px;border:1px solid var(--ui-border-accented);color:var(--ui-text-toned);background:{{ langBg }}"><span aria-hidden="true" style="display:inline-block;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/languages.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/languages.svg') center / contain no-repeat"></span>{{ langName }}</span><span style="height:{{ chipH }};padding:0 8px;display:flex;align-items:center;gap:6px;border:1px solid var(--ui-border-accented);color:var(--ui-text-toned)"><span aria-hidden="true" style="display:inline-block;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/monitor.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/monitor.svg') center / contain no-repeat"></span>System</span></span>
</div>
</sc-if>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props="{&quot;$preview&quot;:{&quot;width&quot;:480,&quot;height&quot;:620},&quot;s&quot;:{&quot;editor&quot;:null,&quot;tsType&quot;:&quot;object&quot;},&quot;mobile&quot;:{&quot;editor&quot;:&quot;boolean&quot;,&quot;default&quot;:false,&quot;tsType&quot;:&quot;boolean&quot;}}">
<script type="text/x-dc" data-dc-script data-props="{&quot;$preview&quot;:{&quot;width&quot;:480,&quot;height&quot;:620},&quot;s&quot;:{&quot;editor&quot;:null,&quot;tsType&quot;:&quot;object&quot;},&quot;lang&quot;:{&quot;editor&quot;:&quot;enum&quot;,&quot;options&quot;:[&quot;en&quot;,&quot;fr&quot;],&quot;default&quot;:&quot;en&quot;,&quot;tsType&quot;:&quot;string&quot;},&quot;langOpen&quot;:{&quot;editor&quot;:&quot;boolean&quot;,&quot;default&quot;:false,&quot;tsType&quot;:&quot;boolean&quot;},&quot;mobile&quot;:{&quot;editor&quot;:&quot;boolean&quot;,&quot;default&quot;:false,&quot;tsType&quot;:&quot;boolean&quot;}}">
class Component extends DCLogic {
renderVals() {
const s = this.props.s || {}
const m = !!this.props.mobile
const lang = this.props.lang || s.lang || 'en'
const tone = t => ({ bg: 'var(--rc-' + t + '-soft)', fg: 'var(--ui-' + t + ')' })
const notice = s.notice ? { ...s.notice, ...tone(s.notice.tone) } : null
const result = s.result ? { ...s.result, ...tone(s.result.tone), actions: (s.result.actions || []).map((a, i) => i === 0 && !a.secondary ? { act: 'result-' + i, t: a.t, bg: 'var(--ui-primary)', fg: 'var(--ui-bg)', bd: '1px solid var(--ui-primary)' } : { act: 'result-' + i, t: a.t, bg: 'var(--ui-bg)', fg: 'var(--ui-text-highlighted)', bd: '1px solid var(--ui-border-accented)' }) } : null
@@ -87,7 +88,9 @@ class Component extends DCLogic {
return {
s, notice, result, fields,
hasForm: fields.length > 0 && !!s.submit,
hasFooter: !!(s.footer || s.pending || !m),
hasFooter: true,
langOpen: !!(this.props.langOpen ?? s.langOpen), langName: lang === 'fr' ? 'Français' : 'English', langBg: this.props.langOpen ?? s.langOpen ? 'var(--ui-bg-elevated)' : 'transparent', chipH: m ? '44px' : '28px', optH: m ? '44px' : '32px',
langSeg: [['en','English'],['fr','Français']].map(([k,t]) => ({ act: 'lang-' + k, code: k, t, on: lang === k, op: lang === k ? 1 : 0 })),
submitOpacity: s.submit && s.submit.disabled && !s.submit.loading ? '0.4' : '1',
w: m ? '100%' : '400px',
border: m ? '0' : '1px solid var(--ui-border)',
@@ -46,7 +46,7 @@
<main ref="{{ stageRef }}" style="flex:1;min-width:0;height:100vh;overflow:auto;display:flex;align-items:center;justify-content:center;padding:24px">
<div style="flex-shrink:0;width:{{ boxW }};height:{{ boxH }};box-shadow:0 8px 40px rgb(0 0 0 / 0.15);overflow:hidden">
<div onClick="{{ onStage }}" class="{{ hsClass }}" style="width:{{ devW }};height:{{ devH }};zoom:{{ zoom }};position:relative;overflow:hidden;background:var(--ui-bg-muted)">
<sc-if value="{{ isAuth }}"><div style="width:100%;height:100%;overflow:hidden;background:{{ authBg }};padding-top:{{ authTop }};display:flex;flex-direction:column;align-items:center"><dc-import name="AuthCard" s="{{ authS }}" mobile="{{ mobile }}" hint-size="400px,520px"></dc-import></div></sc-if>
<sc-if value="{{ isAuth }}"><div style="width:100%;height:100%;overflow:hidden;background:{{ authBg }};padding-top:{{ authTop }};display:flex;flex-direction:column;align-items:center"><dc-import name="AuthCard" lang="{{ lang }}" lang-open="{{ langOpen }}" s="{{ authS }}" mobile="{{ mobile }}" hint-size="400px,520px"></dc-import></div></sc-if>
<sc-if value="{{ isApp }}"><dc-import name="AppFrame" s="{{ appS }}" mobile="{{ mobile }}" hint-size="100%,100%"></dc-import></sc-if>
<sc-if value="{{ isEmail }}"><div style="width:100%;height:100%;display:flex;flex-direction:column;background:var(--ui-bg)"><div style="flex-shrink:0;padding:14px 20px;border-bottom:1px solid var(--ui-border);display:flex;flex-direction:column;gap:2px"><span style="font-weight:600;font-size:15px;color:var(--ui-text-highlighted)">{{ emailSubject }}</span><span style="color:var(--ui-text-muted)">From RenewCMS · to marta@renew.example</span></div><iframe title="Email" srcDoc="{{ emailHtml }}" style="flex:1;width:100%;border:0"></iframe></div></sc-if>
<sc-if value="{{ mnav }}">
@@ -187,6 +187,9 @@ class Component extends DCLogic {
act(a) {
const s = this.state
if (a === 'usermenu') return this.setState({ userMenu: !s.userMenu })
if (a === 'langmenu') return this.setState({ langOpen: !s.langOpen })
if (a && a.startsWith('lang-')) return this.setState({ lang: a.slice(5), langOpen: false })
if (s.langOpen) this.setState({ langOpen: false })
if (a && a.startsWith('theme-')) return this.setState({ mode: a.slice(6) })
if (s.userMenu) this.setState({ userMenu: false })
if (a === 'pf-reset') return this.setState({ pfReset: 'sent' })
@@ -428,8 +431,8 @@ class Component extends DCLogic {
rootRef: this.rootRef, stageRef: this.stageRef,
devices: devs, modes, flows, hint, tools,
hs: s.hs, hsClass: s.hs ? 'hs' : '', hsJust: s.hs ? 'flex-end' : 'flex-start', hsBg: s.hs ? 'var(--ui-primary)' : 'var(--ui-bg-accented)', toggleHs: () => this.setState({ hs: !s.hs }),
mobile, isAuth, isApp, isEmail,
authS: isAuth ? this.authS() : {}, appS: isApp ? { ...this.appS(), userMenu: s.userMenu, mode: s.mode } : {},
mobile, isAuth, isApp, isEmail, lang: s.lang || 'en', langOpen: !!s.langOpen,
authS: isAuth ? this.authS() : {}, appS: isApp ? { ...this.appS(), userMenu: s.userMenu, mode: s.mode, lang: s.lang || 'en' } : {},
authBg: mobile ? 'var(--ui-bg)' : 'var(--ui-bg-muted)', authTop: mobile ? '40px' : '108px',
emailHtml: isEmail ? this.emailHtml() : '', emailSubject: s.email === 'reset' ? 'Reset your RenewCMS password' : 'Verify your RenewCMS account',
devW: dw + 'px', devH: dh + 'px', zoom: s.zoom, boxW: Math.round(dw * s.zoom) + 'px', boxH: Math.round(dh * s.zoom) + 'px',
@@ -0,0 +1,172 @@
Feature: Language switcher conforms to the official design
Background:
Given the design in docs/design is the visual spec of record for LanguageSwitcher and UserMenu
And the interface supports exactly two languages, English and Français
And each language is always written in its own name, whatever the interface language
And the language rules of features/spa_internationalization.feature still apply
And the language control no longer uses a form select anywhere
And no language preference is sent to the server
Scenario Outline: Account menu lists Profile, Theme, Language and Log out in design order
Given a signed-in user opens the account menu in the <surface>
Then the entries appear in this order
| Profile |
| separator |
| "Theme" section label |
| System, Light, Dark |
| separator |
| "Language" section label |
| English, Français |
| separator |
| Log out |
And the "Theme" and "Language" labels are 11 px, weight 500, uppercase, letter-spacing 0.04em and --ui-text-muted
And the label padding is <label_padding>
And the menu is <size>
And every item row is <row_height> high
Examples:
| surface | label_padding | size | row_height |
| desktop UserMenu popover | 6px 8px 2px | a 224 px popover above the chip | 32 px |
| mobile AccountMenuSheet | 8px 16px 2px | a bottom sheet with grab handle | 48 px |
Scenario Outline: Language rows are a radio group naming each language in its own name
Given the interface language is <locale>
When the account menu is open in the <surface>
Then the "Language" label names a group of two menuitemradio rows
And the rows read "English" with lang "en" and "Français" with lang "fr"
And the row for <locale_name> has aria-checked "true" and a check icon at the right edge
And the other row has aria-checked "false" and no check icon
And no Nuxt UI select, combobox or languages icon row is present in the menu
Examples:
| surface | locale | locale_name |
| desktop UserMenu popover | en | English |
| desktop UserMenu popover | fr | Français |
| mobile AccountMenuSheet | en | English |
| mobile AccountMenuSheet | fr | Français |
Scenario Outline: Theme rows are radio items that keep the menu open
Given the theme preference is <preference>
When the account menu is open in the <surface>
Then System, Light and Dark are menuitemradio rows in a group named "Theme"
And only the <preference> row has aria-checked "true" and a check icon
When the user chooses another theme row
Then the theme applies at once and the menu stays open
Examples:
| surface | preference |
| desktop UserMenu popover | light |
| mobile AccountMenuSheet | dark |
Scenario Outline: Choosing a language in the account menu applies it at once and keeps the menu open
Given the interface language is en and the user is on /profile with an unsaved display name "Alice B."
And the account menu is open in the <surface>
When the user chooses "Français"
Then all interface text, including the menu labels "Thème" and "Langue", is French
And the document lang is "fr" and localStorage "renewcms-language" is "fr"
And the Français row is checked and the menu stays open
And the route, session, theme and unsaved display name are unchanged
And no reload, navigation or /v1 request occurs
When the user chooses "English"
Then the interface is English, the document lang is "en" and the menu stays open
Examples:
| surface |
| desktop UserMenu popover |
| mobile AccountMenuSheet |
Scenario Outline: Account menu keyboard and dismissal
Given the account menu is open in the <surface>
When the user moves with ArrowDown and ArrowUp
Then focus visits Profile, the theme rows, the language rows and Log out in visual order and wraps at the ends
When the user presses Enter or Space on a language row
Then that language is applied and the menu stays open
When the user presses Escape or clicks outside
Then the menu closes without changing the language
Examples:
| surface |
| desktop UserMenu popover |
| mobile AccountMenuSheet |
Scenario Outline: Auth footer shows the language chip beside the theme chip
Given the user is signed out on <route>
And the interface language is en
Then the footer outside the card contains the language chip and the System theme chip aligned right with an 8 px gap
And any existing footer slot content stays to the left of the chips
And the chip shows the lucide languages icon at 14 px followed by "English"
And the chip has a 1 px --ui-border-accented border, --ui-text-toned text, aria-haspopup "menu", aria-expanded "false" and aria-label "Language: English"
And no language select is rendered in the card or the footer
Examples:
| route |
| /login |
| /register |
| /register/pending |
| /register/validate |
| /register/reset/request |
| /register/reset/validate |
| /invite/accept |
Scenario Outline: Auth footer is always rendered, on mobile too
Given the viewport is <viewport>
When an auth screen with no footer slot content renders
Then the footer with the language chip and the theme chip is visible
And the chip is <chip_height> high
Examples:
| viewport | chip_height |
| 1440 x 900 | 28 px |
| 390 x 844 | 44 px |
Scenario: Chip reflects the current language
Given the stored language is fr
When /login renders
Then the chip reads "Français" and its aria-label is "Langue : Français"
Scenario Outline: Auth language popover
Given the user is signed out on /login in <locale_name>
When the user activates the language chip
Then the chip has aria-expanded "true" and --ui-bg-elevated
And a popover with role "menu" named "Language" opens above the chip, right-aligned and 36 px above its bottom edge, 168 px wide, --ui-bg, 1 px --ui-border, --rc-shadow-pop and 4 px padding
And it holds menuitemradio rows "English" (lang "en") and "Français" (lang "fr") that are 32 px high on desktop and 44 px on mobile
And the current language row has aria-checked "true" and a 16 px check icon at the right edge
Examples:
| locale_name |
| English |
| Français |
Scenario: Choosing a language in the auth popover closes it
Given the auth language popover is open on /login with English current
When the user chooses "Français"
Then the whole screen is French at once, including the card, the footer and the document title
And the document lang is "fr" and localStorage "renewcms-language" is "fr"
And the popover closes and focus returns to the language chip
And the chip reads "Français"
And the form values already typed, the route and the theme are unchanged
And no /v1 request occurs
Scenario: Dismiss the auth popover without changing language
Given the auth language popover is open
When the user presses Escape or clicks outside it
Then it closes, aria-expanded is "false" and the language is unchanged
And focus returns to the chip on Escape
Scenario: Auth popover keyboard
Given the language chip has focus
When the user presses Enter, Space or ArrowDown
Then the popover opens with focus on the current language row
And ArrowUp and ArrowDown move between the two rows and Enter or Space chooses one
Scenario: Language persists across public and signed-in screens
Given the user chose Français on /login
When they sign in, open the account menu and log out
Then Français is checked in the account menu and the /login chip reads "Français" again
And a storage failure still lets the choice apply for the current session, as in spa_internationalization
Scenario: Catalog parity for the new strings
Then the en and fr catalogs contain "Theme" and "Language: {0}"
And "Language: {0}" keeps its {0} parameter in French
And the strings "English" and "Français" are not translated
@@ -0,0 +1,35 @@
# Language switcher conformance QA
Accepted by the user on 2026-10-04 together with the slice specification. Decisions folded in: spa_internationalization picker scenarios are amended; theme and language rows are menuitemradio and the account menu and sheet stay open after a choice; the auth popover is specified from docs/design/components.md because the boards draw only the chip.
## Automated (Vitest + Vue Test Utils, `task test:web`)
Mock `/v1` per case and assert that a language change makes no `/v1` request. Reset the stored `renewcms-language` between tests. Do not freeze the system date.
1. UserMenu (desktop) and AccountMenuSheet (mobile): open the menu and assert the entry order Profile, separator, "Theme" label, System/Light/Dark, separator, "Language" label, English/Français, separator, Log out. Assert the label classes. LanguagePicker and any combobox must be absent.
2. Roles and ARIA: theme rows and language rows are menuitemradio with aria-checked on the current one and a check icon. Language rows carry lang "en"/"fr" and native names. With locale fr the names stay "English"/"Français" while "Theme"/"Language" become "Thème"/"Langue".
3. Account menu selection: choosing Français in each surface switches the visible copy, sets document lang and storage, keeps the menu open, and leaves route, theme and an unsaved display-name draft on /profile unchanged. Choosing English restores it. Choosing a theme keeps the mobile sheet open.
4. Account menu keyboard: arrow order and wrap across all entries, Enter/Space applies and keeps open, Escape and outside click close with no language change.
5. AuthShell: the footer is rendered with the language chip next to UColorModeSelect when belowSm is true and false, with no footer slot content, and slot content stays to the left. Assert aria-haspopup, aria-expanded, aria-label "Language: English" / "Langue : Français", the icon, the native name and the absence of a select.
6. Auth popover: opens via click, Enter, Space and ArrowDown; role menu named "Language", two menuitemradio rows with lang and checked state; selection applies the language, closes it and returns focus to the chip; Escape and outside click dismiss with no change. Typed form values on /login survive the switch.
7. Each auth screen (login, register, pending, verify, forgot-password, reset-password, accept-invite) mounts with the chip present.
8. Persistence: stored fr gives chip "Français"; the choice survives log out and re-render of /login; a localStorage write failure still switches for the session.
9. Catalog parity: en/fr keys include "Theme" and "Language: {0}" with matching parameters.
10. Update web/src/i18n/spa.spec.ts, web/src/i18n/i18n.spec.ts and any AuthShell, UserMenu or AccountMenuSheet spec asserting menuitemcheckbox or the old picker. Remove LanguagePicker and its tests if the component is removed.
11. Run task test:web and the type check; inspect the diff for introduced comments.
## Visual (`task visual:check -- <screen>`, hard gate at 10 %)
Desktop and mobile, light and dark, reusing the boards' sample data.
- profile, state "Account menu": board `deskMenu` and `mobMenu` with the Theme and Language labels, Light and English checked.
- Every auth screen and state (log-in, register, pending, verify, forgot-password, reset-password, accept-invite): all captures change because of the language chip (`hasFooter: true`), including mobile where the footer is new. Clock-sensitive states stay `skip: 'clock-sensitive'`.
- The boards draw no open auth language popover, so it has no visual gate. It is covered by the automated and manual checks.
## Manual (screenshots cannot cover)
- Keyboard focus order and visible focus ring in the account menu, the chip and the popover; focus return to the chip.
- Hover, press and motion on the rows and the chip.
- Popover placement above the chip and containment at 390 px width, including the 36 px offset next to the 44 px mobile chip. Touch targets at 44 px on mobile.
- French readability of "Thème", "Langue" and "Langue : Français" in the labels, chip and popover.
- Language retained after reload and logout.
+9 -13
View File
@@ -4,8 +4,7 @@ Feature: English and French throughout the SPA
Given all existing SPA interface text supports English and French
And English preserves existing copy and French provides equivalent meaning
And user content, API data and transactional emails remain unchanged
And visual checks are waived by the user for this slice
And the language control uses existing design tokens
And the language control follows features/language_switcher_conformance.feature
Scenario Outline: Resolve the language before rendering
Given the browser-local stored language is <stored>
@@ -21,12 +20,10 @@ Feature: English and French throughout the SPA
| unsupported | en |
| unreadable | en |
Scenario Outline: Open the language picker in the profile submenu
Scenario Outline: Offer the language choice in the account menu
Given the user opens the <menu>
When they activate the localized Language control
Then a compact scrollable picker offers English and Français
Then a localized Language section offers English and Français as radio items
And the selected language is visibly and accessibly identified
And the picker uses Nuxt UI and existing design tokens
Examples:
| menu |
@@ -65,13 +62,12 @@ Feature: English and French throughout the SPA
And an unavailable French message falls back to English
And user content, account details, filenames, URLs, API data and emails remain unchanged
Scenario Outline: Operate the picker accessibly
Given the picker is open in the <menu>
When the user navigates its options with the keyboard and confirms a choice
Then the language changes and the picker closes
And focus returns to its language control
When they reopen it and press Escape or click outside
Then the picker closes without changing language
Scenario Outline: Operate the language choice accessibly
Given the <menu> is open
When the user navigates the language items with the keyboard and confirms a choice
Then the language changes and the menu stays open
When they press Escape or click outside
Then the menu closes without changing language
Examples:
| menu |
+2 -4
View File
@@ -5,7 +5,7 @@ Accepted by the user on 2026-10-03 together with the slice specification.
## Automated
1. Verify absent, English, French, unsupported and unreadable stored preferences; English defaults independently of browser language and document lang follows selection.
2. Verify desktop UserMenu and mobile AccountMenuSheet offer a localized language entry and a compact bounded scrollable picker with English and Français, selected-state semantics, selection, dismissal and focus return. Preserve profile, theme and logout behavior.
2. Verify desktop UserMenu and mobile AccountMenuSheet offer a localized Language section with English and Français radio items, selected-state semantics, selection with the menu staying open, and dismissal. Preserve profile, theme and logout behavior. Details live in features/language_switcher_conformance.qa.md.
3. Switch both ways while editing an unsaved profile display name. Verify translated UI and unchanged draft, route, session and theme without additional API requests.
4. Verify browser-local persistence across reload initialization, navigation and logout/public authentication. Verify storage write failure allows switching for the current session.
5. Preserve existing English tests and add French checks for login, validation, profile save failure, integration copy feedback, not-found, representative dialogs and notifications.
@@ -16,6 +16,4 @@ Accepted by the user on 2026-10-03 together with the slice specification.
## Visual and manual
Visual checks are SKIPPED by explicit user waiver for this slice because the designer is unavailable. The user authorized a coherent language control using existing design tokens. Do not edit design sources or claim pixel parity.
Manual checklist: desktop/mobile and light/dark coherence, viewport containment, constrained scrolling, French text readability, touch and mouse selection, keyboard movement and confirmation, Escape/outside dismissal, focus return and visible focus. Confirm language retention after reload and logout. Do not freeze dates or investigate clock-sensitive states.
The language control now has an official design. Its visual and manual checks live in features/language_switcher_conformance.qa.md. For the rest of this slice, confirm French text readability and language retention after reload and logout. Do not freeze dates or investigate clock-sensitive states.
+53 -26
View File
@@ -1,13 +1,15 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { locale, t } from '@/i18n'
import { ref } from 'vue'
import { ref, useId } from 'vue'
import type { ColorModePreference } from '@/composables/useColorMode'
import { colorModeOptions, useAccountMenu } from '@/composables/useAccountMenu'
import LanguagePicker from '@/components/LanguagePicker.vue'
import { chooseLanguage, languageOptions } from '@/composables/useLanguage'
const ITEM_SELECTOR = '[role="menuitem"], [role="menuitemcheckbox"], [role="combobox"]'
const ITEM_CLASS = 'h-12 justify-start gap-3 px-4 text-[0.9375rem] text-highlighted'
const ITEM_SELECTOR = '[role="menuitem"], [role="menuitemradio"]'
const ITEM_CLASS = 'h-12 justify-start gap-3 px-4 text-[0.9375rem] font-normal text-highlighted'
const SECTION_LABEL_CLASS =
'px-4 pt-2 pb-0.5 text-[0.6875rem] font-medium uppercase tracking-[0.04em] text-muted'
const props = defineProps<{
username: string
@@ -29,6 +31,8 @@ const {
goToProfile: navigateToProfile,
} = useAccountMenu(() => props)
const menu = ref<HTMLElement | null>(null)
const themeLabelId = useId()
const languageLabelId = useId()
function goToProfile() {
open.value = false
@@ -37,7 +41,6 @@ function goToProfile() {
function chooseColorMode(value: ColorModePreference) {
colorMode.preference = value
open.value = false
}
function logOut() {
@@ -46,7 +49,6 @@ function logOut() {
}
function moveFocus(event: KeyboardEvent) {
if ((event.target as HTMLElement).closest('[role="combobox"]')) return
const items = Array.from(menu.value?.querySelectorAll<HTMLElement>(ITEM_SELECTOR) ?? [])
if (items.length === 0) {
return
@@ -96,7 +98,7 @@ function moveFocus(event: KeyboardEvent) {
:text="initials"
:ui="{
root: 'size-9 bg-secondary-300',
fallback: 'text-[0.8125rem] font-semibold text-primary-900',
fallback: 'text-[0.8125rem] pt-0.5 font-semibold text-primary-900',
}"
/>
<div class="flex min-w-0 flex-col">
@@ -121,26 +123,51 @@ function moveFocus(event: KeyboardEvent) {
</UButton>
<USeparator class="my-1" />
<LanguagePicker mobile />
<div role="group" :aria-labelledby="themeLabelId">
<div :id="themeLabelId" :class="SECTION_LABEL_CLASS">{{ t('Theme') }}</div>
<UButton
v-for="option in colorModeOptions"
:key="option.value"
role="menuitemradio"
color="neutral"
variant="ghost"
block
:aria-checked="colorMode.preference === option.value"
:trailing-icon="
colorMode.preference === option.value ? 'i-lucide-check' : undefined
"
:class="ITEM_CLASS"
:ui="{ trailingIcon: 'ms-auto size-4.5' }"
@click="chooseColorMode(option.value)"
>
{{ option.label }}
</UButton>
</div>
<USeparator class="my-1" />
<UButton
v-for="option in colorModeOptions"
:key="option.value"
role="menuitemcheckbox"
color="neutral"
variant="ghost"
block
:aria-checked="colorMode.preference === option.value"
:trailing-icon="
colorMode.preference === option.value ? 'i-lucide-check' : undefined
"
:class="ITEM_CLASS"
:ui="{ trailingIcon: 'ms-auto size-4.5' }"
@click="chooseColorMode(option.value)"
>
{{ option.label }}
</UButton>
<div role="group" :aria-labelledby="languageLabelId">
<div :id="languageLabelId" :class="SECTION_LABEL_CLASS">
{{ t('Language') }}
</div>
<UButton
v-for="option in languageOptions"
:key="option.value"
role="menuitemradio"
color="neutral"
variant="ghost"
block
:lang="option.value"
:aria-checked="locale === option.value"
:trailing-icon="locale === option.value ? 'i-lucide-check' : undefined"
:class="ITEM_CLASS"
:ui="{ trailingIcon: 'ms-auto size-4.5' }"
@click="chooseLanguage(option.value)"
>
{{ option.label }}
</UButton>
</div>
<USeparator class="my-1" />
+9 -5
View File
@@ -3,6 +3,7 @@ import { t } from '@/i18n'
import { computed, onMounted, onUnmounted, ref, useSlots, watch } from 'vue'
import AppLogo from '@/components/AppLogo.vue'
import LanguageChip from '@/components/LanguageChip.vue'
import { isInAppNavigation } from '@/composables/useAuthShellNavigation'
const props = defineProps<{
@@ -61,12 +62,12 @@ const footerClass = computed(() => [
cardClass.value,
])
const colorModeSelectUi = {
base: 'h-7 ps-7 pe-2 text-sm text-toned',
const colorModeSelectUi = computed(() => ({
base: belowSm.value ? 'h-11 ps-7 pe-2 text-sm text-toned' : 'h-7 ps-7 pe-2 text-sm text-toned',
leading: 'ps-2',
leadingIcon: 'size-3.5 text-toned',
trailing: 'hidden',
}
}))
watch(
() => props.title,
@@ -118,11 +119,14 @@ watch(
/>
</UPageCard>
<footer v-if="!belowSm || $slots.footer" :class="footerClass">
<footer :class="footerClass">
<span class="flex flex-wrap items-center gap-1.5">
<slot name="footer" />
</span>
<UColorModeSelect size="sm" :ui="colorModeSelectUi" />
<span class="flex items-center gap-2">
<LanguageChip :mobile="belowSm" />
<UColorModeSelect size="sm" :ui="colorModeSelectUi" />
</span>
</footer>
</main>
</template>
+63
View File
@@ -0,0 +1,63 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { locale, t } from '@/i18n'
import { chooseLanguage, languageName, languageOptions } from '@/composables/useLanguage'
import { LANGUAGE_MENU_CLASS, useLanguageMenuRadios } from '@/composables/useRadioMenuRoles'
const props = defineProps<{ mobile?: boolean }>()
const open = ref(false)
useLanguageMenuRadios(open)
const items = computed(() =>
languageOptions.map((option) => ({
type: 'checkbox' as const,
label: option.label,
checked: locale.value === option.value,
class: props.mobile
? 'h-11 items-center py-0 text-base font-normal text-highlighted'
: 'h-8 items-center py-0 text-base font-normal text-highlighted',
onUpdateChecked: () => chooseLanguage(option.value),
})),
)
const chipClass = computed(() => [
props.mobile ? 'h-11' : 'h-7',
'gap-1.5 rounded-none border border-accented bg-transparent px-2 text-sm font-normal text-toned ring-0 hover:bg-elevated',
open.value ? 'bg-elevated' : '',
])
function focusCurrentRow() {
document.querySelector<HTMLElement>(`.${LANGUAGE_MENU_CLASS} [aria-checked="true"]`)?.focus()
}
watch(open, (value) => {
if (value) setTimeout(focusCurrentRow, 0)
})
</script>
<template>
<UDropdownMenu
v-model:open="open"
:items="items"
:content="{
side: 'top',
align: 'end',
sideOffset: 8,
loop: true,
}"
:ui="{ content: `w-42 p-1 ${LANGUAGE_MENU_CLASS}`, itemTrailingIcon: 'size-4' }"
>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-languages"
:aria-label="t('Language: {0}', [languageName])"
:aria-expanded="open"
:class="chipClass"
:ui="{ leadingIcon: 'size-3.5 text-toned' }"
>
{{ languageName }}
</UButton>
</UDropdownMenu>
</template>
-44
View File
@@ -1,44 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { locale, setLocale, t, type Locale } from '@/i18n'
defineProps<{ mobile?: boolean }>()
const languages = [
{ label: 'English', value: 'en' },
{ label: 'Français', value: 'fr' },
]
const selected = computed({
get: () => locale.value,
set: (value: string) => setLocale(value as Locale),
})
</script>
<template>
<div
:class="mobile ? 'flex items-center gap-3 px-4 py-2' : 'flex items-center gap-2 px-2 py-1'"
>
<UIcon name="i-lucide-languages" class="size-4 shrink-0 text-toned" />
<USelect
v-model="selected"
:items="languages"
:aria-label="t('Language')"
:content="{
side: mobile ? 'top' : 'right',
align: 'start',
collisionPadding: 16,
}"
:ui="{
content: 'w-40',
viewport: 'max-h-40 overflow-y-auto',
base: 'min-w-0 flex-1',
}"
:class="mobile ? 'h-11' : 'h-8'"
>
<template #default>
<span class="truncate">{{ t('Language') }}</span>
</template>
</USelect>
</div>
</template>
+53 -45
View File
@@ -1,13 +1,17 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { locale, t } from '@/i18n'
import { computed, ref } from 'vue'
import { useEventListener, useMediaQuery } from '@vueuse/core'
import { useMediaQuery } from '@vueuse/core'
import { colorModeOptions, useAccountMenu } from '@/composables/useAccountMenu'
import LanguagePicker from '@/components/LanguagePicker.vue'
import { chooseLanguage, languageOptions } from '@/composables/useLanguage'
import { useAccountMenuRadios } from '@/composables/useRadioMenuRoles'
const DESKTOP_QUERY = '(min-width: 64rem)'
const ITEM_CLASS = 'h-8 items-center py-0'
const ITEM_CLASS = 'h-8 items-center gap-2.5 px-2 py-0 text-sm font-normal text-highlighted'
const CHOICE_CLASS = 'h-8 items-center gap-2.5 px-2 py-0 text-base font-normal text-highlighted'
const SECTION_LABEL_CLASS =
'px-2 pt-1.5 pb-0.5 text-[0.6875rem] font-medium uppercase tracking-[0.04em] text-muted'
const props = defineProps<{
username: string
@@ -25,31 +29,8 @@ const emit = defineEmits<{
const { colorMode, name, initials, onProfile, goToProfile } = useAccountMenu(() => props)
const isDesktop = useMediaQuery(DESKTOP_QUERY)
const open = ref(false)
useAccountMenuRadios(open)
const chip = ref<{ $el: HTMLElement } | null>(null)
const languagePicker = ref<{ $el: HTMLElement } | null>(null)
function focusLanguage(event: KeyboardEvent) {
const target = event.target
if (!(target instanceof HTMLElement)) return
if (target.closest('[role="menu"]') !== languagePicker.value?.$el.closest('[role="menu"]'))
return
if (target.closest('[role="combobox"]')) return
const entries = Array.from(
target
.closest('[role="menu"]')
?.querySelectorAll<HTMLElement>('[role="menuitem"], [role="menuitemcheckbox"]') ?? [],
)
const movesToLanguage =
event.key === 'End' ||
(event.key === 'ArrowDown' && target === entries[entries.length - 1]) ||
(event.key === 'ArrowUp' && target === entries[0])
if (movesToLanguage) {
event.preventDefault()
languagePicker.value?.$el.querySelector<HTMLElement>('[role="combobox"]')?.focus()
}
}
useEventListener(document, 'keydown', focusLanguage, { capture: true })
const chipActive = computed(() => open.value || onProfile.value)
@@ -94,18 +75,44 @@ const items = computed(() => [
onSelect: goToProfile,
},
],
colorModeOptions.map((option) => ({
type: 'checkbox' as const,
label: option.label,
checked: colorMode.preference === option.value,
class: ITEM_CLASS,
onSelect: (event: Event) => event.preventDefault(),
onUpdateChecked: (checked: boolean) => {
if (checked) {
colorMode.preference = option.value
}
[
{
type: 'label' as const,
get label() {
return t('Theme')
},
class: SECTION_LABEL_CLASS,
},
})),
...colorModeOptions.map((option) => ({
type: 'checkbox' as const,
label: option.label,
checked: colorMode.preference === option.value,
class: CHOICE_CLASS,
onSelect: (event: Event) => event.preventDefault(),
onUpdateChecked: (checked: boolean) => {
if (checked) {
colorMode.preference = option.value
}
},
})),
],
[
{
type: 'label' as const,
get label() {
return t('Language')
},
class: SECTION_LABEL_CLASS,
},
...languageOptions.map((option) => ({
type: 'checkbox' as const,
label: option.label,
checked: locale.value === option.value,
class: CHOICE_CLASS,
onSelect: (event: Event) => event.preventDefault(),
onUpdateChecked: () => chooseLanguage(option.value),
})),
],
[
{
get label() {
@@ -123,8 +130,12 @@ const items = computed(() => [
<UDropdownMenu
:open="open"
:items="items"
:content="{ side: 'top', align: 'start' }"
:ui="{ content: 'w-56 p-1' }"
:content="{ side: 'top', align: 'start', sideOffset: 4, loop: true }"
:ui="{
content: 'w-56 p-0 ring-0 border border-default',
itemLeadingIcon: 'size-4 text-highlighted',
itemTrailingIcon: 'size-4',
}"
@update:open="onUpdateOpen"
>
<UButton
@@ -156,11 +167,8 @@ const items = computed(() => [
<template #account-header>
<span class="flex min-w-0 flex-col gap-0.5">
<span class="truncate text-sm font-semibold text-highlighted">{{ name }}</span>
<span class="truncate text-xs font-normal text-muted">{{ email }}</span>
<span class="truncate text-xs/5 font-normal text-muted">{{ email }}</span>
</span>
</template>
<template #content-bottom>
<LanguagePicker ref="languagePicker" />
</template>
</UDropdownMenu>
</template>
@@ -296,10 +296,18 @@ describe('Account menu: opened from the drawer on mobile', () => {
expect(menu.querySelector('[data-slot="fallback"]')?.textContent).toBe('MA')
expect(menu.textContent).toContain('marta@renew.example')
const labels = Array.from(
menu.querySelectorAll('[role="menuitem"], [role="menuitemcheckbox"]'),
menu.querySelectorAll('[role="menuitem"], [role="menuitemradio"]'),
).map((item) => item.textContent?.trim())
expect(labels).toEqual(['Profile', 'System', 'Light', 'Dark', 'Log out'])
menu.querySelectorAll('[role="menuitem"], [role="menuitemcheckbox"]').forEach((item) =>
expect(labels).toEqual([
'Profile',
'System',
'Light',
'Dark',
'English',
'Français',
'Log out',
])
menu.querySelectorAll('[role="menuitem"], [role="menuitemradio"]').forEach((item) =>
expect(item.className).toContain('h-12'),
)
})
@@ -325,7 +333,7 @@ describe('Account menu: opened from the drawer on mobile', () => {
expect(document.body.querySelector('[role="menu"]')).toBeNull()
})
it('applies a theme choice and closes the sheet', async () => {
it('applies a theme choice and keeps the sheet open', async () => {
const colorMode = useColorMode()
colorMode.preference = 'system'
await mountShell('/home', { width: MOBILE_WIDTH })
@@ -339,13 +347,13 @@ describe('Account menu: opened from the drawer on mobile', () => {
await flushPromises()
const dark = Array.from(
document.body.querySelectorAll<HTMLElement>('[role="menu"] [role="menuitemcheckbox"]'),
document.body.querySelectorAll<HTMLElement>('[role="menu"] [role="menuitemradio"]'),
).find((item) => item.textContent?.trim() === 'Dark')!
dark.click()
await flushPromises()
expect(colorMode.preference).toBe('dark')
expect(document.body.querySelector('[role="menu"]')).toBeNull()
expect(document.body.querySelector('[role="menu"]')).not.toBeNull()
})
it('logs out from the sheet', async () => {
+21 -5
View File
@@ -51,7 +51,7 @@ function menu() {
function itemsIn(container: ParentNode) {
return Array.from(
container.querySelectorAll('[role="menuitem"], [role="menuitemcheckbox"]'),
container.querySelectorAll('[role="menuitem"], [role="menuitemradio"]'),
) as HTMLElement[]
}
@@ -98,18 +98,26 @@ describe('The chip opens the account menu instead of logging out', () => {
})
describe('Account menu: opened on desktop', () => {
it('lists the header, Profile, System, Light, Dark and Log out with a divider between its three groups', async () => {
it('lists the header, Profile, the theme and language choices and Log out with a divider between its groups', async () => {
await mountMenu()
await openMenu()
const header = menu()!.querySelector('[role="group"]')!
expect(header.textContent).toContain('marta')
expect(header.textContent).toContain('marta@renew.example')
expect(itemLabels()).toEqual(['Profile', 'System', 'Light', 'Dark', 'Log out'])
expect(itemLabels()).toEqual([
'Profile',
'System',
'Light',
'Dark',
'English',
'Français',
'Log out',
])
expect(
menu()!.querySelector('[data-slot="viewport"]')!.classList.contains('divide-y'),
).toBe(true)
expect(menu()!.querySelectorAll('[role="group"]')).toHaveLength(3)
expect(menu()!.querySelectorAll('[role="group"]')).toHaveLength(4)
})
it('shows the display name in the header and the chip when there is one', async () => {
@@ -297,7 +305,15 @@ describe('The collapsed rail keeps the menu and an accessible name', () => {
await openMenu()
expect(itemLabels()).toEqual(['Profile', 'System', 'Light', 'Dark', 'Log out'])
expect(itemLabels()).toEqual([
'Profile',
'System',
'Light',
'Dark',
'English',
'Français',
'Log out',
])
})
})
@@ -0,0 +1,840 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import ui from '@nuxt/ui/vue-plugin'
import type { Component } from 'vue'
import App from '@/App.vue'
import AuthShell from '@/components/AuthShell.vue'
import AccountMenuSheet from '@/components/AccountMenuSheet.vue'
import UserMenu from '@/components/UserMenu.vue'
import { useApi } from '@/composables/useApi'
import { useColorMode } from '@/composables/useColorMode'
import { useSessionStore } from '@/stores/session'
import { initializeLocale, locale, setLocale, t } from '@/i18n'
import { messages } from '@/i18n/messages'
import router from '@/router'
import { DESKTOP_WIDTH, MOBILE_WIDTH, setViewportWidth } from './viewport'
vi.mock('@/composables/useApi', async () => {
const { ref } = await import('vue')
return {
useApi: vi.fn(),
authRedirectPending: ref(false),
ApiError: class extends Error {
constructor(
public status: number,
public retryAfter?: number,
public body?: string,
) {
super('API failure')
}
},
}
})
const request = vi.fn()
const SIGNED_IN = {
id: 1,
verified: true,
username: 'marta',
displayName: '',
email: 'marta@renew.example',
role: 'owner',
}
let wrapper: VueWrapper | undefined
function signIn() {
const session = useSessionStore()
session.session = SIGNED_IN
session.status = 'ready'
}
function signOut() {
const session = useSessionStore()
session.session = null
session.status = 'ready'
}
beforeEach(() => {
localStorage.clear()
initializeLocale()
setViewportWidth(DESKTOP_WIDTH)
setActivePinia(createPinia())
useColorMode().preference = 'light'
request.mockReset().mockResolvedValue([])
vi.mocked(useApi).mockReturnValue({
request,
getMe: vi.fn().mockResolvedValue(null),
} as unknown as ReturnType<typeof useApi>)
})
afterEach(async () => {
vi.restoreAllMocks()
wrapper?.unmount()
wrapper = undefined
document.body.innerHTML = ''
localStorage.clear()
initializeLocale()
await router.push('/login')
})
function press(element: Element, value: string) {
element.dispatchEvent(
new KeyboardEvent('keydown', { key: value, bubbles: true, cancelable: true }),
)
}
function pointerActivate(element: Element) {
element.dispatchEvent(new MouseEvent('pointerup', { bubbles: true }))
element.dispatchEvent(new MouseEvent('click', { bubbles: true }))
}
function clickOutside() {
const outside = document.createElement('button')
document.body.append(outside)
outside.dispatchEvent(
Object.assign(new MouseEvent('pointerdown', { bubbles: true, button: 0 }), {
pointerType: 'mouse',
}),
)
outside.click()
}
function menu() {
return document.body.querySelector<HTMLElement>('[role="menu"]')
}
function rows(container: ParentNode = document.body) {
return Array.from(
container.querySelectorAll<HTMLElement>('[role="menuitem"], [role="menuitemradio"]'),
)
}
function row(label: string) {
return rows().find((item) => item.textContent?.trim() === label)!
}
function checked(items: HTMLElement[]) {
return items.filter((item) => item.getAttribute('aria-checked') === 'true')
}
function sectionLabel(text: string) {
return Array.from(menu()!.querySelectorAll<HTMLElement>('div')).find(
(el) => el.textContent?.trim() === text && el.className.includes('uppercase'),
)!
}
async function nextFrame() {
await new Promise((resolve) => setTimeout(resolve, 0))
await new Promise((resolve) => setTimeout(resolve, 0))
await flushPromises()
}
type Surface = 'desktop UserMenu popover' | 'mobile AccountMenuSheet'
const surfaces: Surface[] = ['desktop UserMenu popover', 'mobile AccountMenuSheet']
async function openAccountMenu(surface: Surface) {
signIn()
await router.push('/profile')
if (surface === 'desktop UserMenu popover') {
wrapper = mount(UserMenu, {
attachTo: document.body,
props: { username: 'marta', email: SIGNED_IN.email, collapsed: false },
global: { plugins: [ui, router] },
})
document.body.querySelector<HTMLButtonElement>('button[aria-haspopup]')!.click()
} else {
wrapper = mount(AccountMenuSheet, {
attachTo: document.body,
props: { username: 'marta', email: SIGNED_IN.email, open: true },
global: { plugins: [ui, router] },
})
}
await nextFrame()
}
function authChip() {
return document.body.querySelector<HTMLButtonElement>('footer button[aria-haspopup="menu"]')!
}
async function mountAuthShell(belowSm: boolean, slots: Record<string, string> = {}) {
useColorMode().preference = 'system'
setViewportWidth(belowSm ? MOBILE_WIDTH : DESKTOP_WIDTH)
wrapper = mount(AuthShell, {
attachTo: document.body,
props: { title: 'Log in' },
slots,
global: { plugins: [ui] },
})
await flushPromises()
}
describe('Account menu lists Profile, Theme, Language and Log out in design order', () => {
it.each(surfaces)('orders the entries in the %s', async (surface) => {
await openAccountMenu(surface)
const text = menu()!.textContent!
const order = [
'Profile',
'Theme',
'System',
'Light',
'Dark',
'Language',
'English',
'Français',
'Log out',
]
const positions = order.map((entry) =>
text.indexOf(entry, entry === 'Language' ? text.indexOf('Dark') : 0),
)
expect(positions).toEqual([...positions].sort((a, b) => a - b))
expect(positions.every((position) => position >= 0)).toBe(true)
expect(rows().map((item) => item.textContent?.trim())).toEqual([
'Profile',
'System',
'Light',
'Dark',
'English',
'Français',
'Log out',
])
})
it.each(surfaces)(
'styles the section labels and removes the old picker in the %s',
async (surface) => {
await openAccountMenu(surface)
for (const label of ['Theme', 'Language']) {
const classes = sectionLabel(label).className
expect(classes).toContain('text-[0.6875rem]')
expect(classes).toContain('font-medium')
expect(classes).toContain('uppercase')
expect(classes).toContain('tracking-[0.04em]')
expect(classes).toContain('text-muted')
expect(classes).toContain(
surface === 'desktop UserMenu popover' ? 'pt-1.5' : 'pt-2',
)
expect(classes).toContain(surface === 'desktop UserMenu popover' ? 'px-2' : 'px-4')
}
expect(document.body.querySelector('[role="combobox"], [role="listbox"]')).toBeNull()
expect(
menu()!.querySelector('[data-icon="languages"], .iconify--lucide--languages'),
).toBeNull()
},
)
it.each(surfaces)('gives every row the design height in the %s', async (surface) => {
await openAccountMenu(surface)
for (const item of rows()) {
expect(item.className).toContain(
surface === 'desktop UserMenu popover' ? 'h-8' : 'h-12',
)
}
})
})
describe('Language rows are a radio group naming each language in its own name', () => {
it.each(
surfaces.flatMap((surface) => [
[surface, 'en', 'English'],
[surface, 'fr', 'Français'],
]),
)('in the %s with locale %s checks %s', async (surface, code, name) => {
setLocale(code as 'en' | 'fr')
await openAccountMenu(surface as Surface)
const group = menu()!.querySelector<HTMLElement>(
`[role="group"][aria-labelledby="${sectionLabel(t('Language')).id}"]`,
)!
const languageRows = rows(group)
expect(languageRows.map((item) => item.getAttribute('role'))).toEqual([
'menuitemradio',
'menuitemradio',
])
expect(
languageRows.map((item) => [item.textContent?.trim(), item.getAttribute('lang')]),
).toEqual([
['English', 'en'],
['Français', 'fr'],
])
expect(checked(languageRows).map((item) => item.textContent?.trim())).toEqual([name])
expect(
languageRows.filter((item) =>
item.querySelector('svg[data-slot="itemTrailingIcon"], svg.iconify'),
).length,
).toBe(1)
expect(sectionLabel(t('Language')).textContent).toBe(code === 'fr' ? 'Langue' : 'Language')
expect(sectionLabel(t('Theme')).textContent).toBe(code === 'fr' ? 'Thème' : 'Theme')
})
})
describe('Theme rows are radio items that keep the menu open', () => {
it.each([
['desktop UserMenu popover', 'light'],
['mobile AccountMenuSheet', 'dark'],
] as const)('in the %s with preference %s', async (surface, preference) => {
useColorMode().preference = preference
await openAccountMenu(surface)
const themeRows = ['System', 'Light', 'Dark'].map(row)
expect(themeRows.map((item) => item.getAttribute('role'))).toEqual([
'menuitemradio',
'menuitemradio',
'menuitemradio',
])
expect(checked(themeRows).map((item) => item.textContent?.trim())).toEqual([
preference === 'light' ? 'Light' : 'Dark',
])
pointerActivate(row('System'))
await flushPromises()
expect(useColorMode().preference).toBe('system')
expect(menu()).not.toBeNull()
expect(row('System').getAttribute('aria-checked')).toBe('true')
})
})
describe('Choosing a language in the account menu applies it at once and keeps the menu open', () => {
it.each(surfaces)('in the %s', async (surface) => {
await openAccountMenu(surface)
request.mockClear()
pointerActivate(row('Français'))
await flushPromises()
expect([
locale.value,
document.documentElement.lang,
localStorage.getItem('renewcms-language'),
menu() !== null,
row('Français').getAttribute('aria-checked'),
row('English').getAttribute('aria-checked'),
sectionLabel('Thème') !== undefined,
sectionLabel('Langue') !== undefined,
row('Profil') !== undefined,
request,
router.currentRoute.value.path,
useColorMode().preference,
]).toEqual([
'fr',
'fr',
'fr',
true,
'true',
'false',
true,
true,
true,
expect.objectContaining({ mock: expect.objectContaining({ calls: [] }) }),
'/profile',
'light',
])
pointerActivate(row('English'))
await flushPromises()
expect([locale.value, document.documentElement.lang, menu() !== null]).toEqual([
'en',
'en',
true,
])
})
it.each(['desktop', 'mobile'] as const)(
'keeps an unsaved display name on /profile in the %s layout',
async (layout) => {
signIn()
setViewportWidth(layout === 'desktop' ? DESKTOP_WIDTH : MOBILE_WIDTH)
await router.push('/profile')
wrapper = mount(App, { attachTo: document.body, global: { plugins: [ui, router] } })
await flushPromises()
await wrapper.find('input[autocomplete="off"]').setValue('Alice B.')
if (layout === 'mobile') {
document.body.querySelector<HTMLButtonElement>('[data-slot="toggle"]')!.click()
await flushPromises()
}
document.body
.querySelector<HTMLButtonElement>(
layout === 'mobile'
? '[role="dialog"] button[aria-haspopup]'
: 'button[aria-haspopup]',
)!
.click()
await nextFrame()
request.mockClear()
pointerActivate(row('Français'))
await flushPromises()
expect([
locale.value,
(wrapper.find('input[autocomplete="off"]').element as HTMLInputElement).value,
router.currentRoute.value.path,
request.mock.calls.length,
menu() !== null,
]).toEqual(['fr', 'Alice B.', '/profile', 0, true])
},
)
it('applies the language for the session when storage cannot be written', async () => {
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('quota')
})
await openAccountMenu('desktop UserMenu popover')
pointerActivate(row('Français'))
await flushPromises()
expect([locale.value, row('Français').getAttribute('aria-checked')]).toEqual(['fr', 'true'])
})
})
describe('Account menu keyboard and dismissal', () => {
it('wraps the arrow keys across every entry of the desktop popover', async () => {
await openAccountMenu('desktop UserMenu popover')
const items = rows()
items[items.length - 1]!.focus()
press(document.activeElement!, 'ArrowDown')
await flushPromises()
expect(document.activeElement).toBe(items[0])
press(document.activeElement!, 'ArrowUp')
await flushPromises()
expect(document.activeElement).toBe(items[items.length - 1])
const visited: (string | undefined)[] = []
items[0]!.focus()
for (let step = 0; step < items.length; step++) {
visited.push(document.activeElement?.textContent?.trim())
press(document.activeElement!, 'ArrowDown')
await flushPromises()
}
expect(visited).toEqual([
'Profile',
'System',
'Light',
'Dark',
'English',
'Français',
'Log out',
])
})
it('wraps the arrow keys across every entry of the mobile sheet', async () => {
await openAccountMenu('mobile AccountMenuSheet')
const items = rows()
items[items.length - 1]!.focus()
press(document.activeElement!, 'ArrowDown')
expect(document.activeElement).toBe(items[0])
press(document.activeElement!, 'ArrowUp')
expect(document.activeElement).toBe(items[items.length - 1])
const visited: (string | undefined)[] = []
items[0]!.focus()
for (let step = 0; step < items.length; step++) {
visited.push(document.activeElement?.textContent?.trim())
press(document.activeElement!, 'ArrowDown')
}
expect(visited).toEqual([
'Profile',
'System',
'Light',
'Dark',
'English',
'Français',
'Log out',
])
})
it('applies a language with Enter and Space in the desktop popover and keeps it open', async () => {
await openAccountMenu('desktop UserMenu popover')
row('Français').focus()
press(row('Français'), 'Enter')
await flushPromises()
expect([locale.value, menu() !== null]).toEqual(['fr', true])
row('English').focus()
press(row('English'), ' ')
await flushPromises()
expect([locale.value, menu() !== null]).toEqual(['en', true])
})
it('renders the mobile language rows as native buttons so Enter and Space activate them', async () => {
await openAccountMenu('mobile AccountMenuSheet')
expect([row('English').tagName, row('Français').tagName]).toEqual(['BUTTON', 'BUTTON'])
row('Français').click()
await flushPromises()
expect([locale.value, menu() !== null]).toEqual(['fr', true])
})
it.each(surfaces)(
'closes with Escape without changing the language in the %s',
async (surface) => {
await openAccountMenu(surface)
row('Profile').focus()
press(document.activeElement!, 'Escape')
await nextFrame()
expect([menu(), locale.value]).toEqual([null, 'en'])
},
)
it('closes the desktop popover on a click outside without changing the language', async () => {
await openAccountMenu('desktop UserMenu popover')
clickOutside()
await nextFrame()
expect([menu(), locale.value]).toEqual([null, 'en'])
})
it('closes the mobile sheet on a tap on the backdrop without changing the language', async () => {
await openAccountMenu('mobile AccountMenuSheet')
const overlay = document.body.querySelector<HTMLElement>('[data-slot="overlay"]')!
overlay.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true }))
overlay.dispatchEvent(new MouseEvent('pointerup', { bubbles: true }))
overlay.click()
await nextFrame()
expect([menu(), locale.value]).toEqual([null, 'en'])
})
})
describe('Auth footer shows the language chip beside the theme chip', () => {
it.each([false, true])(
'renders both chips, aligned right, with belowSm=%s',
async (belowSm) => {
await mountAuthShell(belowSm)
const footer = document.body.querySelector('footer')!
const chips = footer.lastElementChild as HTMLElement
expect(footer).not.toBeNull()
expect(chips.className).toContain('gap-2')
expect(chips.children).toHaveLength(2)
expect(chips.children[0]).toBe(authChip())
expect(chips.children[1]!.textContent).toContain('System')
expect(authChip().textContent?.trim()).toBe('English')
expect(authChip().getAttribute('aria-haspopup')).toBe('menu')
expect(authChip().getAttribute('aria-expanded')).toBe('false')
expect(authChip().getAttribute('aria-label')).toBe('Language: English')
expect(authChip().querySelector('.iconify--lucide, svg')).not.toBeNull()
expect(authChip().className).toContain('border-accented')
expect(authChip().className).toContain('text-toned')
expect(authChip().className).toContain(belowSm ? 'h-11' : 'h-7')
expect(
document.body.querySelector('[role="combobox"][aria-label*="anguage"]'),
).toBeNull()
},
)
it('keeps the footer slot content to the left of the chips', async () => {
await mountAuthShell(false, { footer: '<a href="/register">Create an account</a>' })
const footer = document.body.querySelector('footer')!
const link = footer.querySelector('a')!
expect(footer.firstElementChild!.contains(link)).toBe(true)
expect(
footer.firstElementChild!.compareDocumentPosition(authChip()) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy()
})
it('reflects the stored language in the chip and its label', async () => {
localStorage.setItem('renewcms-language', 'fr')
initializeLocale()
await mountAuthShell(false)
expect([authChip().textContent?.trim(), authChip().getAttribute('aria-label')]).toEqual([
'Français',
'Langue : Français',
])
})
})
describe('Auth language popover', () => {
async function openPopover() {
await mountAuthShell(false)
authChip().click()
await nextFrame()
}
it.each([
['English', 'en'],
['Français', 'fr'],
])('opens a named menu with two radio rows when the language is %s', async (name, code) => {
setLocale(code as 'en' | 'fr')
await openPopover()
expect(menu()!.getAttribute('aria-label')).toBe(code === 'fr' ? 'Langue' : 'Language')
expect(authChip().getAttribute('aria-expanded')).toBe('true')
expect(authChip().className).toContain('bg-elevated')
expect(
rows().map((item) => [
item.getAttribute('role'),
item.textContent?.trim(),
item.getAttribute('lang'),
]),
).toEqual([
['menuitemradio', 'English', 'en'],
['menuitemradio', 'Français', 'fr'],
])
expect(checked(rows()).map((item) => item.textContent?.trim())).toEqual([name])
expect(menu()!.className).toContain('w-42')
expect(menu()!.className).toContain('p-1')
rows().forEach((item) => {
expect(item.className).toContain('h-8')
expect(item.className).toContain('text-base')
})
})
it.each(['Enter', ' ', 'ArrowDown'])(
'opens with %j on the chip and focuses the current row',
async (keyName) => {
await mountAuthShell(false)
authChip().focus()
press(authChip(), keyName)
await nextFrame()
expect(menu()).not.toBeNull()
expect(document.activeElement).toBe(row('English'))
},
)
it('moves between the two rows with the arrow keys and chooses with Enter', async () => {
await openPopover()
press(document.activeElement!, 'ArrowDown')
await flushPromises()
expect(document.activeElement).toBe(row('Français'))
press(document.activeElement!, 'ArrowUp')
await flushPromises()
expect(document.activeElement).toBe(row('English'))
row('Français').focus()
press(row('Français'), 'Enter')
await nextFrame()
expect(locale.value).toBe('fr')
})
it('applies French on the whole screen, closes and returns focus to the chip', async () => {
signOut()
await router.push('/login')
const { default: view } = await import('@/views/LoginView.vue')
wrapper = mount(view, { attachTo: document.body, global: { plugins: [ui, router] } })
await flushPromises()
authChip().focus()
authChip().click()
await nextFrame()
request.mockClear()
pointerActivate(row('Français'))
await nextFrame()
expect(locale.value).toBe('fr')
expect(document.documentElement.lang).toBe('fr')
expect(localStorage.getItem('renewcms-language')).toBe('fr')
expect(document.title).toBe('Se connecter · RenewCMS')
expect(document.body.textContent).toContain('Mot de passe')
expect(menu()).toBeNull()
expect(document.activeElement).toBe(authChip())
expect(authChip().textContent?.trim()).toBe('Français')
expect(request).not.toHaveBeenCalled()
})
it('dismisses with Escape, keeping the language and returning focus to the chip', async () => {
await openPopover()
press(document.activeElement!, 'Escape')
await nextFrame()
expect([
menu(),
locale.value,
authChip().getAttribute('aria-expanded'),
document.activeElement,
]).toEqual([null, 'en', 'false', authChip()])
})
it('dismisses on a click outside, keeping the language', async () => {
await openPopover()
clickOutside()
await nextFrame()
expect([menu(), locale.value, authChip().getAttribute('aria-expanded')]).toEqual([
null,
'en',
'false',
])
})
})
describe('Each auth screen carries the language chip', () => {
const screens: [string, () => Promise<{ default: Component }>, string][] = [
['/login', () => import('@/views/LoginView.vue'), '/login'],
['/register', () => import('@/views/RegisterView.vue'), '/register'],
['/register/pending', () => import('@/views/PendingView.vue'), '/register/pending'],
['/register/validate', () => import('@/views/VerifyView.vue'), '/register/validate'],
[
'/register/reset/request',
() => import('@/views/ResetRequestView.vue'),
'/register/reset/request',
],
[
'/register/reset/validate',
() => import('@/views/ResetConfirmView.vue'),
'/register/reset/validate',
],
['/invite/accept', () => import('@/views/AcceptInviteView.vue'), '/invite/accept'],
]
it.each(screens)('%s', async (_name, load, path) => {
const session = useSessionStore()
session.session =
path === '/register/pending'
? { verified: false, username: 'marta', email: SIGNED_IN.email }
: null
session.status = 'ready'
await router.push(path)
const { default: view } = await load()
wrapper = mount(view, { attachTo: document.body, global: { plugins: [ui, router] } })
await flushPromises()
expect(authChip().textContent?.trim()).toBe('English')
expect(authChip().getAttribute('aria-label')).toBe('Language: English')
expect(document.body.querySelector('[role="combobox"][aria-label*="anguage"]')).toBeNull()
})
it('keeps typed login values when the language changes', async () => {
signOut()
await router.push('/login')
const { default: view } = await import('@/views/LoginView.vue')
wrapper = mount(view, { attachTo: document.body, global: { plugins: [ui, router] } })
await flushPromises()
await wrapper.find('input[autocomplete="username"]').setValue('marta')
authChip().click()
await nextFrame()
pointerActivate(row('Français'))
await nextFrame()
expect([
(wrapper.find('input[autocomplete="username"]').element as HTMLInputElement).value,
router.currentRoute.value.path,
]).toEqual(['marta', '/login'])
})
})
describe('Language persists across public and signed-in screens', () => {
it('keeps Français after signing in, in the account menu and back on the login chip', async () => {
await mountAuthShell(false)
authChip().click()
await nextFrame()
pointerActivate(row('Français'))
await nextFrame()
wrapper!.unmount()
wrapper = undefined
document.body.innerHTML = ''
await openAccountMenu('desktop UserMenu popover')
expect(row('Français').getAttribute('aria-checked')).toBe('true')
wrapper!.unmount()
wrapper = undefined
document.body.innerHTML = ''
await mountAuthShell(false)
expect(authChip().textContent?.trim()).toBe('Français')
})
it('restores a stored French language on the chip after a reload', async () => {
localStorage.setItem('renewcms-language', 'fr')
initializeLocale()
await mountAuthShell(false)
expect(authChip().textContent?.trim()).toBe('Français')
})
it('still switches for the session when the storage write fails', async () => {
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('quota')
})
await mountAuthShell(false)
authChip().click()
await nextFrame()
pointerActivate(row('Français'))
await nextFrame()
expect([locale.value, authChip().textContent?.trim()]).toEqual(['fr', 'Français'])
})
})
describe('Catalog parity for the new strings', () => {
it('translates Theme and Language: {0} and keeps the parameter', () => {
expect(messages['Theme']).toBe('Thème')
expect(messages['Language: {0}']).toBe('Langue : {0}')
expect(messages['Language: {0}']).toContain('{0}')
})
it('never translates the native language names', () => {
expect(Object.keys(messages)).not.toContain('English')
expect(Object.keys(messages)).not.toContain('Français')
})
})
describe('Radio role patching releases its observer', () => {
async function mountProbe() {
const { defineComponent, h, ref } = await import('vue')
const { useLanguageMenuRadios } = await import('@/composables/useRadioMenuRoles')
const open = ref(false)
const probe = defineComponent({
setup() {
useLanguageMenuRadios(open)
return () => h('div')
},
})
return { open, probe: mount(probe) }
}
it('observes only while open and disconnects on close', async () => {
const observe = vi.spyOn(MutationObserver.prototype, 'observe')
const disconnect = vi.spyOn(MutationObserver.prototype, 'disconnect')
const { open, probe } = await mountProbe()
expect(observe).not.toHaveBeenCalled()
open.value = true
await flushPromises()
expect(observe).toHaveBeenCalledTimes(1)
expect(disconnect).not.toHaveBeenCalled()
open.value = false
await flushPromises()
expect(disconnect).toHaveBeenCalledTimes(1)
probe.unmount()
expect(disconnect).toHaveBeenCalledTimes(1)
})
it('disconnects when unmounted while open', async () => {
const disconnect = vi.spyOn(MutationObserver.prototype, 'disconnect')
const { open, probe } = await mountProbe()
open.value = true
await flushPromises()
probe.unmount()
expect(disconnect).toHaveBeenCalledTimes(1)
})
})
@@ -33,8 +33,7 @@ let currentWrapper: Awaited<ReturnType<typeof mount>> | null = null
async function mountLogin(path = '/login') {
setActivePinia(createPinia())
global.fetch = (() =>
mockResponse(200, { open: false })) as unknown as typeof fetch
global.fetch = (() => mockResponse(200, { open: false })) as unknown as typeof fetch
await router.push(path)
const wrapper = mount(LoginView, {
attachTo: document.body,
@@ -78,9 +77,7 @@ describe('A field error shows the circle-alert icon with no extra focus halo', (
await wrapper.find('form').trigger('submit')
await flushPromises()
const fieldRoots = wrapper.findAll(
'[data-slot="root"][data-orientation]',
)
const fieldRoots = wrapper.findAll('[data-slot="root"][data-orientation]')
const usernameError = fieldRoots[0]!.find('[data-slot="error"]')
const passwordError = fieldRoots[1]!.find('[data-slot="error"]')
@@ -97,14 +94,12 @@ describe('A field error shows the circle-alert icon with no extra focus halo', (
expect(usernameInput.classes()).toContain('ring-error')
expect(usernameInput.classes()).toContain('focus-visible:ring-error')
expect(usernameInput.classes()).not.toContain('ring-primary')
expect(usernameInput.classes()).not.toContain(
'focus-visible:ring-primary',
)
expect(usernameInput.classes()).not.toContain('focus-visible:ring-primary')
})
})
describe('The theme select is right-aligned in the footer with no chevron', () => {
it('renders as the last child, showing only the mode icon and label', async () => {
it('renders as the last chip, showing only the mode icon and label', async () => {
stubMatchMedia(false)
const wrapper = await mountLogin()
@@ -112,8 +107,8 @@ describe('The theme select is right-aligned in the footer with no chevron', () =
const select = footer.find('button[aria-haspopup="listbox"]')
expect(select.exists()).toBe(true)
const children = Array.from(footer.element.children)
expect(children[children.length - 1]).toBe(select.element)
const chips = footer.element.lastElementChild as HTMLElement
expect(chips.lastElementChild).toBe(select.element)
expect(select.text()).toContain('System')
@@ -146,31 +141,19 @@ describe('The alert renders inside the card, under the description', () => {
expect(card.element.contains(alert.element)).toBe(true)
expect(alert.text()).toContain(text)
const rootsInCard = card.findAll(
'[data-slot="root"][data-orientation]',
)
expect(
rootsInCard.some((root) =>
root.classes().includes('bg-primary'),
),
).toBe(false)
const rootsInCard = card.findAll('[data-slot="root"][data-orientation]')
expect(rootsInCard.some((root) => root.classes().includes('bg-primary'))).toBe(false)
const usernameInput = wrapper.find(
'input[placeholder="Your username"]',
)
const usernameInput = wrapper.find('input[placeholder="Your username"]')
const html = card.html()
expect(html.indexOf('Welcome back')).toBeGreaterThan(-1)
expect(html.indexOf('Welcome back')).toBeLessThan(
html.indexOf(text),
)
expect(html.indexOf('Welcome back')).toBeLessThan(html.indexOf(text))
expect(html.indexOf(text)).toBeLessThan(html.indexOf('Username'))
expect(card.element.contains(usernameInput.element)).toBe(true)
expect(
alert
.classes()
.some((c) => /^bg-\(--rc-(info|success)-soft\)$/.test(c)),
).toBe(true)
expect(alert.classes().some((c) => /^bg-\(--rc-(info|success)-soft\)$/.test(c))).toBe(
true,
)
},
)
})
+15
View File
@@ -0,0 +1,15 @@
import { computed } from 'vue'
import { locale, setLocale, type Locale } from '@/i18n'
export const languageOptions: { label: string; value: Locale }[] = [
{ label: 'English', value: 'en' },
{ label: 'Français', value: 'fr' },
]
export const languageName = computed(
() => languageOptions.find((option) => option.value === locale.value)?.label ?? 'English',
)
export function chooseLanguage(value: Locale) {
setLocale(value)
}
+63
View File
@@ -0,0 +1,63 @@
import { onBeforeUnmount, watch, type Ref } from 'vue'
import { t } from '@/i18n'
import { languageOptions } from '@/composables/useLanguage'
export const LANGUAGE_MENU_CLASS = 'rc-language-menu'
function groupLabel(group: Element) {
const id = group.getAttribute('aria-labelledby')
return id ? document.getElementById(id)?.textContent?.trim() : undefined
}
function toRadio(item: Element) {
item.setAttribute('role', 'menuitemradio')
const language = languageOptions.find((option) => option.label === item.textContent?.trim())
if (language) item.setAttribute('lang', language.value)
}
function radiofyAccountMenu() {
const labels = [t('Theme'), t('Language')]
for (const group of document.querySelectorAll('[role="menu"] [role="group"]')) {
const label = groupLabel(group)
if (!label || !labels.includes(label)) continue
group.querySelectorAll('[role="menuitemcheckbox"]').forEach(toRadio)
}
}
function radiofyLanguageMenu() {
for (const menu of document.querySelectorAll(`[role="menu"].${LANGUAGE_MENU_CLASS}`)) {
menu.setAttribute('aria-label', t('Language'))
menu.querySelectorAll('[role="menuitemcheckbox"]').forEach(toRadio)
}
}
function applyWhileOpen(open: Ref<boolean>, apply: () => void) {
let observer: MutationObserver | null = null
function stop() {
observer?.disconnect()
observer = null
}
watch(
open,
(value) => {
stop()
if (!value) return
apply()
observer = new MutationObserver(apply)
observer.observe(document.body, { childList: true, subtree: true })
},
{ immediate: true },
)
onBeforeUnmount(stop)
}
export function useAccountMenuRadios(open: Ref<boolean>) {
applyWhileOpen(open, radiofyAccountMenu)
}
export function useLanguageMenuRadios(open: Ref<boolean>) {
applyWhileOpen(open, radiofyLanguageMenu)
}
+2
View File
@@ -4,6 +4,8 @@ export const messages = {
unselected: 'désélectionnée',
'Log in': 'Se connecter',
Language: 'Langue',
Theme: 'Thème',
'Language: {0}': 'Langue : {0}',
Profile: 'Profil',
'Log out': 'Se déconnecter',
Account: 'Compte',
+1 -218
View File
@@ -3,16 +3,13 @@ import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import ui from '@nuxt/ui/vue-plugin'
import App from '@/App.vue'
import LanguagePicker from '@/components/LanguagePicker.vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import UserMenu from '@/components/UserMenu.vue'
import AccountMenuSheet from '@/components/AccountMenuSheet.vue'
import { useApi, ApiError } from '@/composables/useApi'
import { useSessionStore } from '@/stores/session'
import { useColorMode } from '@/composables/useColorMode'
import router from '@/router'
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
import { initializeLocale, locale, setLocale, t } from './index'
import { initializeLocale, setLocale, t } from './index'
import { messages } from './messages'
import { formatAbsoluteDateFromIso } from '@/lib/relativeDate'
import { formatSize, pluralArticles } from '@/lib/mediaFormat'
@@ -84,22 +81,6 @@ async function mountRoute(path: string) {
return wrapper
}
function trigger() {
return document.body.querySelector<HTMLButtonElement>(
'[role="combobox"][aria-label="Language"], [role="combobox"][aria-label="Langue"]',
)!
}
function key(element: Element, value: string) {
element.dispatchEvent(
new KeyboardEvent('keydown', {
key: value,
bubbles: true,
cancelable: true,
}),
)
}
describe('Translate the entire interface', () => {
it('provides nonempty equivalent interpolation parameters for every English and French key', () => {
const placeholders = (text: string) =>
@@ -321,152 +302,6 @@ describe('Switch language without losing work', () => {
})
})
describe('Operate the picker accessibly', () => {
it.each([false, true])(
'supports keyboard selection and focus return with mobile=%s',
async (mobile) => {
wrapper = mount(LanguagePicker, {
attachTo: document.body,
props: { mobile },
global: { plugins: [ui] },
})
trigger().focus()
key(trigger(), 'ArrowDown')
await flushPromises()
const options = Array.from(
document.body.querySelectorAll<HTMLElement>('[role="option"]'),
)
const selected = options
.find((option) => option.textContent!.includes('English'))
?.getAttribute('aria-selected')
key(document.activeElement!, 'End')
await flushPromises()
await new Promise((resolve) => setTimeout(resolve, 0))
key(document.activeElement!, 'Enter')
await flushPromises()
expect({
options: options.map((option) => option.textContent!.trim()),
selected,
locale: locale.value,
closed: !document.body.querySelector('[role="listbox"]'),
focus: document.activeElement === trigger(),
}).toEqual({
options: ['English', 'Français'],
selected: 'true',
locale: 'fr',
closed: true,
focus: true,
})
},
)
it('dismisses with Escape and preserves the language', async () => {
wrapper = mount(LanguagePicker, {
attachTo: document.body,
global: { plugins: [ui] },
})
trigger().focus()
key(trigger(), 'ArrowDown')
await flushPromises()
key(document.activeElement!, 'Escape')
await flushPromises()
expect([
locale.value,
!document.body.querySelector('[role="listbox"]'),
document.activeElement === trigger(),
]).toEqual(['en', true, true])
})
it.each(['desktop', 'mobile'])('offers the picker in the %s account menu', async (menu) => {
useSessionStore().session = {
verified: true,
username: 'marta',
displayName: '',
}
useSessionStore().status = 'ready'
await router.push('/profile')
wrapper =
menu === 'desktop'
? mount(UserMenu, {
attachTo: document.body,
props: { username: 'marta', collapsed: false },
global: { plugins: [ui, router] },
})
: mount(AccountMenuSheet, {
attachTo: document.body,
props: { username: 'marta', open: true },
global: { plugins: [ui, router] },
})
if (menu === 'desktop')
document.body.querySelector<HTMLButtonElement>('button[title="Account"]')!.click()
await flushPromises()
const profileEntry = Array.from(
document.body.querySelectorAll<HTMLElement>('[role="menuitem"]'),
).find((item) => item.textContent?.trim() === 'Profile')!
profileEntry.focus()
key(profileEntry, menu === 'desktop' ? 'End' : 'ArrowDown')
await new Promise((resolve) => setTimeout(resolve, 0))
const reachedControl = document.activeElement === trigger()
key(trigger(), 'ArrowDown')
await flushPromises()
key(document.activeElement!, 'End')
await new Promise((resolve) => setTimeout(resolve, 0))
key(document.activeElement!, 'Enter')
await flushPromises()
expect([
reachedControl,
locale.value,
document.activeElement === trigger(),
trigger().textContent?.includes('Langue'),
]).toEqual([true, 'fr', true, true])
})
it('retains French after logout and renders the public authentication screen in French', async () => {
setLocale('fr')
await mountRoute('/profile')
document.body.querySelector<HTMLButtonElement>('button[title="Compte"]')!.click()
await flushPromises()
Array.from(document.body.querySelectorAll<HTMLElement>('[role="menuitem"]'))
.find((item) => item.textContent?.trim() === 'Se déconnecter')!
.click()
await flushPromises()
expect([
router.currentRoute.value.path,
locale.value,
localStorage.getItem('renewcms-language'),
document.body.textContent!.includes('Se connecter'),
]).toEqual(['/login', 'fr', 'fr', true])
})
it('updates an existing success notification without translating the saved account name', async () => {
await mountRoute('/profile')
await wrapper!.find('input[autocomplete="off"]').setValue('Log in')
request.mockResolvedValueOnce({ display_name: 'Log in' })
await wrapper!.find('form').trigger('submit')
await flushPromises()
setLocale('fr')
await flushPromises()
expect([
document.body.textContent!.includes('Nom d’affichage enregistré'),
document.body.textContent!.includes(
'Vous apparaissez désormais sous le nom Log in dans RenewCMS et les e-mails.',
),
]).toEqual([true, true])
})
it('updates an already-open confirmation dialog', async () => {
await mountRoute('/profile')
await wrapper!.find('input[autocomplete="off"]').setValue('Unsaved')
const navigation = router.push('/home')
await flushPromises()
setLocale('fr')
await flushPromises()
const translated = document.body
.querySelector('[role="alertdialog"]')
?.textContent?.includes('Quitter sans enregistrer ?')
Array.from(document.body.querySelectorAll<HTMLButtonElement>('[role="alertdialog"] button'))
.find((button) => button.textContent?.trim() === 'Abandonner les modifications')!
.click()
await navigation
expect(translated).toBe(true)
})
})
describe('Hardener localization outcomes', () => {
it.each([false, true])(
'updates not-found breadcrumbs round-trip with authenticated=%s',
@@ -563,58 +398,6 @@ describe('Hardener localization outcomes', () => {
)
vi.unstubAllGlobals()
})
it.each(['desktop', 'mobile'] as const)(
'dismisses nested picker with Escape and outside press in %s menu',
async (menu) => {
useSessionStore().session = { verified: true, username: 'marta', displayName: '' }
useSessionStore().status = 'ready'
await router.push('/profile')
wrapper =
menu === 'desktop'
? mount(UserMenu, {
attachTo: document.body,
props: { username: 'marta', collapsed: false },
global: { plugins: [ui, router] },
})
: mount(AccountMenuSheet, {
attachTo: document.body,
props: { username: 'marta', open: true },
global: { plugins: [ui, router] },
})
if (menu === 'desktop')
document.body.querySelector<HTMLButtonElement>('button[title="Account"]')!.click()
await flushPromises()
trigger().focus()
key(trigger(), 'ArrowDown')
await flushPromises()
key(document.activeElement!, 'Escape')
await flushPromises()
const escape = [
locale.value,
!document.body.querySelector('[role="listbox"]'),
document.activeElement === trigger(),
]
trigger().focus()
key(trigger(), 'ArrowDown')
await flushPromises()
const outside = document.createElement('button')
outside.textContent = 'Outside'
document.body.append(outside)
outside.dispatchEvent(
Object.assign(new MouseEvent('pointerdown', { bubbles: true, button: 0 }), {
pointerType: 'mouse',
}),
)
outside.click()
await flushPromises()
expect([
escape,
locale.value,
!document.body.querySelector('[role="listbox"]'),
document.activeElement === trigger(),
]).toEqual([['en', true, true], 'en', true, true])
},
)
it.each(['/article', '/article/12/edit'])(
'updates already-open delete descriptions round-trip from %s preserving title and image sentence',
async (path) => {
+7
View File
@@ -938,6 +938,12 @@ async function openAccountMenu(page: Page) {
await page.locator('[data-slot="footer"] button[aria-haspopup]').click()
await page.locator('[role="menu"]').waitFor()
}
await page.getByRole('menuitemradio', { name: 'Light', exact: true }).click()
await page.evaluate(() => {
const dark = window.matchMedia('(prefers-color-scheme: dark)').matches
document.documentElement.classList.toggle('dark', dark)
document.documentElement.classList.toggle('light', !dark)
})
await page.mouse.move(0, 0)
}
@@ -1978,6 +1984,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
api: {
'GET /v1/auth/me': verifiedSession,
'GET /v1/article/': { status: 200, body: sixArticlesFixture },
'GET /v1/system/update': { status: 200, body: updateStatusBody() },
},
steps: openAccountMenu,
},