diff --git a/.changeset/site-kit-footer-locale-menu.md b/.changeset/site-kit-footer-locale-menu.md new file mode 100644 index 0000000..6b94ee6 --- /dev/null +++ b/.changeset/site-kit-footer-locale-menu.md @@ -0,0 +1,7 @@ +--- +"@devslab/site-kit": minor +--- + +`SiteFooter`'s language row collapses into a `
` 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`의 언어 행이 `
` 메뉴로 접힙니다. 트리거는 **현재 언어의 자기 이름**입니다(페이지를 못 읽는 사람도 알아보는 유일한 라벨). 열려 있든 접혀 있든 로케일 앵커는 전부 문서에 남아 크롤러가 따라가고 JS 없이 동작하며, Esc로 닫고 포커스가 트리거로 돌아옵니다(헤더 국기 메뉴와 동일). 평평한 행은 제품마다 길이가 달라(10개·14개·20개) 같은 푸터가 파는 언어 수에 따라 다른 무게를 지고 있었습니다. diff --git a/packages/site-kit/src/solid/__tests__/footer.test.tsx b/packages/site-kit/src/solid/__tests__/footer.test.tsx index 7df95db..bdb9312 100644 --- a/packages/site-kit/src/solid/__tests__/footer.test.tsx +++ b/packages/site-kit/src/solid/__tests__/footer.test.tsx @@ -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(".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(".site-footer__langs-list a")]; expect(links).toHaveLength(FAMILY_LOCALES.LOCALES.length); const korean = links.find((a) => a.getAttribute("hreflang") === "ko")!; @@ -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", () => { @@ -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", ]) { @@ -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(() => `/${code}` }} + />); + const details = host.querySelector(".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(() => `/${code}` }} + />); + const details = host.querySelector(".site-footer__langs-menu")!; + const trigger = details.querySelector("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/); +}); diff --git a/packages/site-kit/src/solid/chrome.tsx b/packages/site-kit/src/solid/chrome.tsx index b450cba..3c961d6 100644 --- a/packages/site-kit/src/solid/chrome.tsx +++ b/packages/site-kit/src/solid/chrome.tsx @@ -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 `
` 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; + 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 = (event) => { + if (event.key !== "Escape" || !details?.open) return; + event.preventDefault(); + close(); + trigger?.focus(); + }; + return ( + + ); +} + export interface SiteFooterProps { brand: SiteBrand; links: SiteLink[]; @@ -147,18 +205,12 @@ export function SiteFooter(props: SiteFooterProps) {