diff --git a/.changeset/site-kit-family-footer.md b/.changeset/site-kit-family-footer.md new file mode 100644 index 0000000..9e27a3e --- /dev/null +++ b/.changeset/site-kit-family-footer.md @@ -0,0 +1,7 @@ +--- +"@devslab/site-kit": minor +--- + +`SiteFooter` renders the family footer: an optional language row (`locale`, `localeRegistry`, `onLocaleSelect`), the brand mark it already accepted but dropped, middot-separated `family` links after the brand name, and a copyright isolated in `` with an optional `copyrightHref`. `styles.css` gains the `.site-footer__langs` / `__row` / `__brand` rules. Additive — a caller passing only brand/links/copyright/messages keeps its single row, plus its mark. VisionLinq, BookLinq and TraceLinq each carried a hand-written copy of this footer, and each left a note saying extending `SiteFooter` was a dds release they were waiting on; this is that release. + +`SiteFooter`가 가족 푸터를 그립니다 — 선택적 언어 행(`locale`·`localeRegistry`·`onLocaleSelect`), 받고도 버리던 브랜드 마크, 브랜드 이름 뒤 가운뎃점으로 이어지는 `family` 링크, ``로 감싼 저작권(선택적 `copyrightHref`). `styles.css`에 `.site-footer__langs`·`__row`·`__brand` 규칙 추가. 덧붙이기만 하므로 brand/links/copyright/messages만 넘기던 호출부는 한 행 그대로에 마크만 더해집니다. VisionLinq·BookLinq·TraceLinq가 각자 이 푸터를 손으로 짜 놓고 저마다 "SiteFooter 확장은 기다려야 할 dds 릴리스"라고 적어 뒀는데, 그 릴리스입니다. diff --git a/packages/site-kit/src/solid/__tests__/footer.test.tsx b/packages/site-kit/src/solid/__tests__/footer.test.tsx new file mode 100644 index 0000000..a40cfe1 --- /dev/null +++ b/packages/site-kit/src/solid/__tests__/footer.test.tsx @@ -0,0 +1,146 @@ +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { render } from "solid-js/web"; +import { afterEach, expect, it } from "vitest"; + +import * as kit from "../index"; +import { FAMILY_LOCALES, defineLocaleRegistry } from "../../core/locales.mjs"; + +let dispose: (() => void) | undefined; +afterEach(() => { + dispose?.(); + dispose = undefined; + document.body.replaceChildren(); +}); + +function mount(node: () => any) { + const host = document.body.appendChild(document.createElement("div")); + dispose = render(node, host); + return host; +} + +const STYLES = readFileSync( + resolve(dirname(fileURLToPath(import.meta.url)), "../../../styles.css"), + "utf8", +); + +const messages = { + navigationLabel: "Navigation", + localeLabel: "Language", + themeLabel: "Theme", + themeSystem: "System", + themeLight: "Light", + themeDark: "Dark", + menuOpen: "Open menu", + menuClose: "Close menu", + footerLabel: "Footer", + skipToContent: "Skip to content", + updatedLabel: "Updated", + notFoundTitle: "Not found", + notFoundDescription: "No such page", + backHome: "Home", + errorTitle: "Error", + errorDescription: "Something failed", + retry: "Retry", +}; + +const base = { + brand: { name: "AskLinq", href: "/" }, + links: [{ href: "/privacy", label: "Privacy" }], + copyright: "© 2026 DevsLab", + messages, +}; + +it("renders one row when no language row is asked for", () => { + const { SiteFooter } = kit; + const host = mount(() => ); + // A product whose header already offers every language does not want a + // second copy of the list, so the row appears only when `locale` is passed. + expect(host.querySelector(".site-footer__langs")).toBeNull(); + expect(host.querySelector(".site-footer__brand strong")?.textContent).toBe("AskLinq"); + expect([...host.querySelectorAll(".site-footer__links a")].map((a) => a.textContent)).toEqual(["Privacy"]); +}); + +it("renders the brand mark the caller passes", () => { + const { SiteFooter } = kit; + // The kit used to accept `brand.logo` and drop it, which is why AskLinq's + // footer shipped without the family mark while every other product drew one. + const host = mount(() => }} />); + expect(host.querySelector(".site-footer__brand [data-testid='mark']")).not.toBeNull(); +}); + +it("lists every family language, marking the current one, and reports the pick", () => { + const { SiteFooter } = kit; + const picked: string[] = []; + const host = mount(() => `/${code}` }} + onLocaleSelect={(code) => picked.push(code)} + />); + const links = [...host.querySelectorAll(".site-footer__langs a")]; + expect(links).toHaveLength(FAMILY_LOCALES.LOCALES.length); + + const korean = links.find((a) => a.getAttribute("hreflang") === "ko")!; + expect(korean.getAttribute("aria-current")).toBe("page"); + expect(korean.getAttribute("href")).toBe("/ko"); + + const arabic = links.find((a) => a.getAttribute("hreflang") === "ar"); + // Each link is written in its own language, so it carries its own direction. + if (arabic) expect(arabic.getAttribute("dir")).toBe("rtl"); + expect(links.filter((a) => a.getAttribute("aria-current") === "page")).toHaveLength(1); + + korean.click(); + expect(picked).toEqual(["ko"]); +}); + +it("honours a locale subset registry", () => { + const { SiteFooter } = kit; + const registry = defineLocaleRegistry({ only: ["ko", "en", "ja"] }); + const host = mount(() => `/${code}` }} + localeRegistry={registry} + />); + expect([...host.querySelectorAll(".site-footer__langs a")].map((a) => a.getAttribute("hreflang"))).toEqual(["ko", "en", "ja"]); +}); + +it("puts the family links after the brand, separated for sighted readers only", () => { + const { SiteFooter } = kit; + const host = mount(() => ); + const brand = host.querySelector(".site-footer__brand")!; + expect([...brand.querySelectorAll("a")].map((a) => a.textContent)).toEqual(["Linq family", "DevsLab"]); + // The middots are decoration between names; a screen reader should read the + // names, not "dot". + expect([...brand.querySelectorAll("span")].every((s) => s.getAttribute("aria-hidden") === "true")).toBe(true); +}); + +it("isolates the copyright so an RTL page does not reorder it, with or without a link", () => { + const { SiteFooter } = kit; + const plain = mount(() => ); + expect(plain.querySelector(".site-footer__links bdi")?.textContent).toBe("© 2026 DevsLab"); + expect(plain.querySelector(".site-footer__links li:last-child a")).toBeNull(); + + dispose?.(); + dispose = undefined; + document.body.replaceChildren(); + + const linked = mount(() => ); + const anchor = linked.querySelector(".site-footer__links li:last-child a")!; + expect(anchor.getAttribute("href")).toBe("https://devslab.kr/"); + expect(anchor.querySelector("bdi")?.textContent).toBe("© 2026 DevsLab"); +}); + +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__row", + ".site-footer__brand", + ]) { + expect(STYLES).toContain(rule); + } +}); diff --git a/packages/site-kit/src/solid/chrome.tsx b/packages/site-kit/src/solid/chrome.tsx index 10eee5d..b450cba 100644 --- a/packages/site-kit/src/solid/chrome.tsx +++ b/packages/site-kit/src/solid/chrome.tsx @@ -1,8 +1,8 @@ import { Button, Icon, IconButton } from "@devslab/dds-solid"; -import { For, createSignal, onMount, type JSX } from "solid-js"; +import { For, Show, createSignal, onMount, type JSX } from "solid-js"; import { LocaleMenu, type LocaleMenuProps, type LocaleMenuVariant } from "./locale-menu"; -import type { LocaleRegistry } from "../core/locales.mjs"; +import { FAMILY_LOCALES, type LocaleRegistry, type SiteLocale } from "../core/locales.mjs"; import type { LocaleState, SiteBrand, SiteLink, SiteMessages, ThemePreference } from "./types"; export type { LocaleMenuProps }; @@ -109,16 +109,79 @@ export interface SiteFooterProps { brand: SiteBrand; links: SiteLink[]; copyright: string; + /** Wraps the copyright in a link — the family site, usually. */ + copyrightHref?: string; messages: SiteMessages; + /** + * The language row. Omit it and no row is rendered: a product whose header + * already offers every language may not want a second copy of the list. + */ + locale?: LocaleState; + /** The languages the row lists. Defaults to the family's fourteen. */ + localeRegistry?: LocaleRegistry; + /** + * Called with the code the reader picked, before the browser follows the + * link — for a product that remembers the choice in a cookie. + */ + onLocaleSelect?: (locale: string) => void; + /** Links after the brand name, middot-separated: the family line, the operator. */ + family?: SiteLink[]; } +/** + * The family footer: a language row, then the brand beside its family links, + * then the page's links and the copyright. + * + * Every product wrote this by hand. VisionLinq, BookLinq and TraceLinq each + * carried their own copy with a comment saying the kit's footer "takes only a + * flat link list; extending it is a dds release this page would then wait on" — + * three copies and three notes naming the same missing release. Their CSS had + * already converged byte-for-byte. This is that release. + * + * Additive: a caller that passes only brand/links/copyright/messages gets the + * same single row it got before, plus its brand mark if it set one. + */ export function SiteFooter(props: SiteFooterProps) { + const registry = () => props.localeRegistry ?? (FAMILY_LOCALES as LocaleRegistry); return ( ); diff --git a/packages/site-kit/styles.css b/packages/site-kit/styles.css index 931d3ba..48dd9c1 100644 --- a/packages/site-kit/styles.css +++ b/packages/site-kit/styles.css @@ -19,6 +19,15 @@ .site-locale { inline-size: auto; min-inline-size: 9rem; } .site-footer { border-block-start: 1px solid var(--dds-color-border-default); margin-block-start: var(--dds-space-64); padding-block: var(--dds-space-32); } .site-footer__inner { inline-size: min(100% - var(--dds-space-32), 1120px); margin-inline: auto; display: grid; gap: var(--dds-space-16); } +.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__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; } +.site-footer__brand a { color: inherit; text-decoration: none; } +.site-footer__brand a:hover { color: var(--dds-color-text-primary); text-decoration: underline; } .site-legal { max-inline-size: 760px; margin-inline: auto; padding-block: var(--dds-space-40); } .site-status { border: 1px solid var(--dds-color-border-default); border-inline-start-width: var(--dds-space-4); border-radius: var(--dds-radius-md); padding: var(--dds-space-12) var(--dds-space-16); } .site-status--danger { border-inline-start-color: var(--dds-color-status-danger); } diff --git a/packages/site-kit/vitest.config.ts b/packages/site-kit/vitest.config.ts index 5f7178b..fb78e26 100644 --- a/packages/site-kit/vitest.config.ts +++ b/packages/site-kit/vitest.config.ts @@ -16,6 +16,7 @@ export default defineConfig({ "src/solid/__tests__/shells.test.tsx", "src/solid/__tests__/oss-product-mark.test.tsx", "src/solid/__tests__/sections.test.tsx", + "src/solid/__tests__/footer.test.tsx", ], restoreMocks: true, },