fix: language buttons real design / conformance
This commit is contained in:
1 parent
2c8f39273c
commit
0490a4c494
23 files changed
+1408
-412
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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="{"$preview":{"width":480,"height":620},"s":{"editor":null,"tsType":"object"},"mobile":{"editor":"boolean","default":false,"tsType":"boolean"}}">
|
||||
<script type="text/x-dc" data-dc-script data-props="{"$preview":{"width":480,"height":620},"s":{"editor":null,"tsType":"object"},"lang":{"editor":"enum","options":["en","fr"],"default":"en","tsType":"string"},"langOpen":{"editor":"boolean","default":false,"tsType":"boolean"},"mobile":{"editor":"boolean","default":false,"tsType":"boolean"}}">
|
||||
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.
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
@@ -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" />
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user