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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/site-kit-footer-locale-menu.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@devslab/site-kit": minor
---

`SiteFooter`'s language row collapses into a `<details>` menu triggered by the current language's own name. Every locale anchor stays in the document open or closed, so crawlers still follow them and it works without JavaScript; Escape closes it and returns focus to the trigger, as the header's flag menu does. The flat row was a different length in every product — ten locales in one, twenty in another — so the same footer carried a different visual weight depending on how many languages the product sells in.

`SiteFooter`의 언어 행이 `<details>` 메뉴로 접힙니다. 트리거는 **현재 언어의 자기 이름**입니다(페이지를 못 읽는 사람도 알아보는 유일한 라벨). 열려 있든 접혀 있든 로케일 앵커는 전부 문서에 남아 크롤러가 따라가고 JS 없이 동작하며, Esc로 닫고 포커스가 트리거로 돌아옵니다(헤더 국기 메뉴와 동일). 평평한 행은 제품마다 길이가 달라(10개·14개·20개) 같은 푸터가 파는 언어 수에 따라 다른 무게를 지고 있었습니다.
51 changes: 48 additions & 3 deletions packages/site-kit/src/solid/__tests__/footer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,9 @@ it("lists every family language, marking the current one, and reports the pick",
locale={{ locale: "ko", hrefForLocale: (code: string) => `/${code}` }}
onLocaleSelect={(code) => picked.push(code)}
/>);
const links = [...host.querySelectorAll<HTMLAnchorElement>(".site-footer__langs a")];
// Collapsed, but every anchor is in the document: a crawler follows them all
// and the menu works with no JavaScript.
const links = [...host.querySelectorAll<HTMLAnchorElement>(".site-footer__langs-list a")];
expect(links).toHaveLength(FAMILY_LOCALES.LOCALES.length);

const korean = links.find((a) => a.getAttribute("hreflang") === "ko")!;
Expand All @@ -102,7 +104,7 @@ it("honours a locale subset registry", () => {
locale={{ locale: "en", hrefForLocale: (code: string) => `/${code}` }}
localeRegistry={registry}
/>);
expect([...host.querySelectorAll(".site-footer__langs a")].map((a) => a.getAttribute("hreflang"))).toEqual(["ko", "en", "ja"]);
expect([...host.querySelectorAll(".site-footer__langs-list a")].map((a) => a.getAttribute("hreflang"))).toEqual(["ko", "en", "ja"]);
});

it("puts the family links after the brand, separated for sighted readers only", () => {
Expand Down Expand Up @@ -137,7 +139,8 @@ it("isolates the copyright so an RTL page does not reorder it, with or without a
it("ships the rules the three rows need, so no consumer has to keep its own copy", () => {
for (const rule of [
".site-footer__langs",
".site-footer__langs a[aria-current=\"page\"]",
".site-footer__langs-trigger",
".site-footer__langs-list a[aria-current=\"page\"]",
".site-footer__row",
".site-footer__brand",
]) {
Expand All @@ -151,3 +154,45 @@ it("sizes the footer link row, so it does not inherit the body step", () => {
// had added the rule locally.
expect(STYLES).toMatch(/\.site-footer__links \{ font-size: var\(--dds-typo-body-2-font-size\); \}/);
});

it("collapses the language row behind the current language's own name", () => {
const { SiteFooter } = kit;
// The flat row was a different length in every product — ten locales in one,
// twenty in another — so the same footer carried a different weight depending
// on how many languages the product sells in.
const host = mount(() => <SiteFooter
{...base}
locale={{ locale: "ko", hrefForLocale: (code: string) => `/${code}` }}
/>);
const details = host.querySelector<HTMLDetailsElement>(".site-footer__langs-menu")!;
expect(details.open).toBe(false);

const trigger = details.querySelector("summary")!;
// The reader who cannot read this page still recognises their own language's
// name, which is why the trigger is the name and not a translated "Language".
expect(trigger.textContent).toBe("한국어");
expect(trigger.getAttribute("aria-label")).toBe("Language: 한국어");

// The landmark survives the collapse.
const nav = host.querySelector(".site-footer__langs")!;
expect(nav.tagName).toBe("NAV");
expect(nav.getAttribute("aria-label")).toBe("Language");
});

it("closes on Escape and hands focus back to the trigger", () => {
const { SiteFooter } = kit;
const host = mount(() => <SiteFooter
{...base}
locale={{ locale: "en", hrefForLocale: (code: string) => `/${code}` }}
/>);
const details = host.querySelector<HTMLDetailsElement>(".site-footer__langs-menu")!;
const trigger = details.querySelector<HTMLElement>("summary")!;
details.open = true;
details.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
expect(details.open).toBe(false);
expect(document.activeElement).toBe(trigger);
});

it("opens the list upward, because the footer is at the foot of the page", () => {
expect(STYLES).toMatch(/\.site-footer__langs-list \{[^}]*inset-block-end/);
});
76 changes: 64 additions & 12 deletions packages/site-kit/src/solid/chrome.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,64 @@ export function SiteHeader(props: SiteHeaderProps) {
);
}

/**
* The footer's language row, collapsed.
*
* It used to be every language laid out flat, which is a different length in
* every product — ten for one, twenty for another — so the same footer carried
* a different visual weight depending on how many languages the product sells
* in. A `<details>` keeps all of them: the anchors are in the document whether
* it is open or not, so a crawler still follows every locale, and it works with
* no JavaScript. Same mechanism as the header's flag menu, including Escape
* returning focus to the trigger.
*
* The trigger is the current language's own name rather than a translated word
* for "language": it is the one label already meaningful to a reader who cannot
* read the page, and it is what the flat row marked with `aria-current`.
*/
function FooterLocaleMenu(props: {
state: LocaleState;
registry: LocaleRegistry<string>;
label: string;
/* Required key, optional value: the caller always passes it through, and
`exactOptionalPropertyTypes` rejects an explicit undefined on an optional. */
onSelect: ((locale: string) => void) | undefined;
}) {
let details: HTMLDetailsElement | undefined;
let trigger: HTMLElement | undefined;
const current = () => props.registry.LOCALES.find((entry) => entry.code === props.state.locale);
const close = () => { if (details) details.open = false; };
const onKeyDown: JSX.EventHandler<HTMLDetailsElement, KeyboardEvent> = (event) => {
if (event.key !== "Escape" || !details?.open) return;
event.preventDefault();
close();
trigger?.focus();
};
return (
<nav class="site-footer__langs" aria-label={props.label}>
<details ref={details} class="site-footer__langs-menu" onKeyDown={onKeyDown}>
<summary ref={trigger} class="site-footer__langs-trigger" aria-label={`${props.label}: ${current()?.nativeName ?? props.state.locale}`}>
<span lang={current()?.code} dir={current()?.dir}>{current()?.nativeName ?? props.state.locale}</span>
</summary>
<ul class="site-footer__langs-list" role="list">
<For each={props.registry.LOCALES}>{(entry) => (
<li>
<a
href={props.state.hrefForLocale(entry.code as SiteLocale)}
hreflang={entry.code}
lang={entry.code}
dir={entry.dir}
aria-current={entry.code === props.state.locale ? "page" : undefined}
onClick={() => props.onSelect?.(entry.code)}
>{entry.nativeName}</a>
</li>
)}</For>
</ul>
</details>
</nav>
);
}

export interface SiteFooterProps {
brand: SiteBrand;
links: SiteLink[];
Expand Down Expand Up @@ -147,18 +205,12 @@ export function SiteFooter(props: SiteFooterProps) {
<footer class="site-footer" aria-label={props.messages.footerLabel}>
<div class="site-footer__inner">
<Show when={props.locale}>{(locale) => (
<nav class="site-footer__langs" aria-label={props.messages.localeLabel}>
<For each={registry().LOCALES}>{(entry) => (
<a
href={locale().hrefForLocale(entry.code as SiteLocale)}
hreflang={entry.code}
lang={entry.code}
dir={entry.dir}
aria-current={entry.code === locale().locale ? "page" : undefined}
onClick={() => props.onLocaleSelect?.(entry.code)}
>{entry.nativeName}</a>
)}</For>
</nav>
<FooterLocaleMenu
state={locale()}
registry={registry()}
label={props.messages.localeLabel}
onSelect={props.onLocaleSelect}
/>
)}</Show>
<div class="site-footer__row">
<p class="site-footer__brand">
Expand Down
17 changes: 13 additions & 4 deletions packages/site-kit/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,19 @@
inherited the 16px body and read a step larger than the same row on a
sibling product's page. Every consumer that noticed fixed it locally. */
.site-footer__links { font-size: var(--dds-typo-body-2-font-size); }
.site-footer__langs { display: flex; flex-wrap: wrap; gap: var(--dds-space-8) var(--dds-space-16); font-size: var(--dds-typo-caption-font-size); }
.site-footer__langs a { color: var(--dds-color-text-muted); text-decoration: none; }
.site-footer__langs a:hover { color: var(--dds-color-text-primary); }
.site-footer__langs a[aria-current="page"] { color: var(--dds-color-text-primary); font-weight: 600; }
.site-footer__langs { font-size: var(--dds-typo-caption-font-size); }
.site-footer__langs-menu { position: relative; display: inline-block; }
.site-footer__langs-trigger { display: inline-flex; align-items: center; gap: var(--dds-space-4); color: var(--dds-color-text-muted); cursor: pointer; list-style: none; }
.site-footer__langs-trigger::-webkit-details-marker { display: none; }
.site-footer__langs-trigger::after { content: ""; inline-size: 0; block-size: 0; border-inline: 4px solid transparent; border-block-start: 4px solid currentColor; }
.site-footer__langs-menu[open] .site-footer__langs-trigger,
.site-footer__langs-trigger:hover { color: var(--dds-color-text-primary); }
.site-footer__langs-trigger:focus-visible { outline: 2px solid var(--dds-color-border-focus); outline-offset: 2px; }
/* The footer sits at the foot of the page, so the list opens upward. */
.site-footer__langs-list { position: absolute; inset-block-end: calc(100% + var(--dds-space-8)); inset-inline-start: 0; z-index: 20; margin: 0; padding: var(--dds-space-8); max-block-size: 60vh; overflow-y: auto; display: grid; gap: var(--dds-space-4); min-inline-size: 12rem; list-style: none; border: 1px solid var(--dds-color-border-default); border-radius: var(--dds-radius-md); background: var(--dds-color-bg-default); box-shadow: var(--dds-elevation-2); }
.site-footer__langs-list a { display: block; padding: var(--dds-space-4) var(--dds-space-8); border-radius: var(--dds-radius-sm); color: var(--dds-color-text-secondary); text-decoration: none; }
.site-footer__langs-list a:hover { background: var(--dds-color-bg-subtle); color: var(--dds-color-text-primary); }
.site-footer__langs-list a[aria-current="page"] { color: var(--dds-color-text-primary); font-weight: 600; }
.site-footer__row { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: var(--dds-space-16) var(--dds-space-32); }
.site-footer__brand { display: inline-flex; align-items: center; gap: var(--dds-space-8); margin: 0; color: var(--dds-color-text-secondary); }
.site-footer__brand img, .site-footer__brand svg { block-size: 16px; inline-size: 16px; }
Expand Down