@carissa-io/webkit-footer 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,104 @@
1
- # Temporary Holding Version
1
+ # @carissa-io/webkit-footer
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The end of a public website's pages: an index of the site (columns of links, each with a heading) and a legal row under a
4
+ 1px rule. A layout with slots, not a template: no newsletter form, no back-to-top, no icons or arrows, no gradient, no
5
+ default shadow and no motion. A React component, SCSS-themed, safe on the server, with no runtime dependency.
6
+
7
+ **The component has no strings of its own.** Every text and link comes from the site. The look comes from the theme's
8
+ second stylesheet, `@carissa-io/theme-default/webkit.css`, which apps do not load.
9
+
10
+ ```bash
11
+ npm i @carissa-io/webkit-footer
12
+ ```
13
+
14
+ ## Use
15
+
16
+ ```jsx
17
+ import { Footer } from '@carissa-io/webkit-footer'
18
+
19
+ <Footer
20
+ as="footer"
21
+ brand={<Logo />}
22
+ description={t('footer_description')}
23
+ nav_label={t('footer_nav_label')}
24
+ columns={[
25
+ { id: 'product', title: t('product'), links: [{ href: '/passkeys', label: t('passkeys') }, { href: '/pricing', label: t('pricing') }] },
26
+ { id: 'company', title: t('company'), links: [{ href: '/about', label: t('about') }] },
27
+ ]}
28
+ legal={
29
+ <>
30
+ <small>{t('copyright')}</small>
31
+ <a href="/privacy">{t('privacy')}</a>
32
+ </>
33
+ }
34
+ />
35
+ ```
36
+
37
+ | Prop | |
38
+ |---|---|
39
+ | `brand` | a node: the logo or the name, first in the brand block |
40
+ | `description` | a short paragraph under the brand; its measure is limited by `$c-footer-description-max-width` |
41
+ | `columns` | `[{ id, title, links: [{ href, label }] }]`; not an array renders no index |
42
+ | `title_level` | heading level of the column titles, `1` to `6`, default `2`; anything else falls back to `2` |
43
+ | `nav_label` | the accessible name of the index; a non-empty string wraps the index in `<nav aria-label>`, anything else renders a plain `div` |
44
+ | `legal` | a node for the copyright and the legal links, in the second row |
45
+ | `social` | an optional node in the second row, after `legal` |
46
+ | `as` | the root tag, `div` by default; a value that is not a tag name falls back to `div` |
47
+ | `className`, `id`, ... | `className` is appended; unknown props go to the root |
48
+
49
+ Invalid input falls back instead of failing: columns that are not objects or have no usable links are skipped, links without
50
+ a non-empty `href` or without a `label` are skipped, a column without a `title` renders its list without a heading. An empty
51
+ slot (`undefined`, `null`, `false`, `''`) renders nothing, so a footer with only `legal` is valid.
52
+
53
+ ### Layout
54
+
55
+ Below `$c-footer-breakpoint` (40rem) everything is one column: brand, then the index, then the legal row. From there, when
56
+ both a brand block and an index exist, they sit side by side. The index is an auto-fit grid whose columns are at least
57
+ `$c-footer-column-min-width` wide, so it adds or drops columns with the space it has and never overflows. The legal row
58
+ wraps. The DOM order is always brand, index, legal row, and nothing reorders it with CSS.
59
+
60
+ ## Accessibility
61
+
62
+ - **Landmarks.** The root is a `div` because the page frame (a site shell) usually owns the `footer` landmark. Used on its
63
+ own, pass `as="footer"`: a `footer` that is a descendant of `main` or `article` is not a `contentinfo` landmark, so keep
64
+ it outside them.
65
+ - **Navigation.** Pass `nav_label` (translated by the site) so the index is a named `navigation` landmark ("Site map",
66
+ "Footer"). A page with another `nav` (the header) needs the names to differ. Without `nav_label` no landmark is created.
67
+ - **Lists and headings.** Each column is a `ul` labelled by its heading, so a screen reader announces the group and its
68
+ size. Set `title_level` to fit the page outline (the default is `h2`; use a lower level if the footer sits inside a section).
69
+ - **Focus.** Links are plain `<a>`; the focus ring is the theme's and the footer styles no `outline`. Link text is the label
70
+ you pass: make it descriptive on its own.
71
+
72
+ ## Style
73
+
74
+ ```scss
75
+ @use '@carissa-io/webkit-footer/scss' with (
76
+ $c-footer-column-min-width: 12rem,
77
+ $c-footer-description-max-width: 36ch
78
+ );
79
+ ```
80
+
81
+ Classes: `c-footer`, `c-footer__inner`, `c-footer__top` (`--split`), `c-footer__brand`, `c-footer__logo`,
82
+ `c-footer__description`, `c-footer__nav`, `c-footer__column`, `c-footer__title`, `c-footer__list`, `c-footer__item`,
83
+ `c-footer__link`, `c-footer__legal-row`, `c-footer__legal`, `c-footer__social`. Every variable is `!default` (see
84
+ [scss/_variables.scss](scss/_variables.scss)). The defaults are neutral: `inherit`, `transparent`, a `currentcolor` rule, no
85
+ shadow, no gradient, no transition; the theme supplies sizes from its type scale and colours as `var(--c-*)`.
86
+
87
+ | Variable | Default |
88
+ |---|---|
89
+ | `$c-footer-font-family`, `-color`, `-background` | `inherit`, `inherit`, `transparent` |
90
+ | `$c-footer-max-width` | `80rem` (inner block, centred) |
91
+ | `$c-footer-padding-block` | `3rem` |
92
+ | `$c-footer-padding-inline`, `-inline-wide`, `-padding-breakpoint` | `1rem`, `2rem`, `40rem` |
93
+ | `$c-footer-breakpoint` | `40rem` (brand and index side by side from here) |
94
+ | `$c-footer-top-columns`, `-top-gap` | `minmax(0, 1fr) minmax(0, 2fr)`, `3rem` |
95
+ | `$c-footer-column-min-width`, `-column-gap` | `10rem`, `2rem` |
96
+ | `$c-footer-brand-gap` | `1rem` |
97
+ | `$c-footer-description-font-size`, `-line-height`, `-color`, `-max-width` | `inherit`, `1.5`, `inherit`, `40ch` |
98
+ | `$c-footer-title-font-size`, `-font-weight`, `-letter-spacing`, `-text-transform`, `-color`, `-margin-bottom` | `inherit`, `700`, `normal`, `none`, `inherit`, `1rem` |
99
+ | `$c-footer-list-gap` | `0.625rem` |
100
+ | `$c-footer-link-font-size`, `-color`, `-text-decoration`, `-hover-text-decoration` | `inherit`, `inherit`, `none`, `underline` |
101
+ | `$c-footer-rule-color` | `currentcolor` (the 1px rule above the legal row) |
102
+ | `$c-footer-legal-margin-top`, `-padding-top`, `-gap` | `3rem`, `1.5rem`, `0.75rem 1.5rem` |
103
+ | `$c-footer-legal-font-size`, `-color` | `inherit`, `inherit` |
104
+ | `$c-footer-legal-link-color`, `-link-text-decoration` | `inherit`, `underline` (links the site puts in `legal` and `social`; without it they would show the browser blue) |
package/dist/index.js ADDED
@@ -0,0 +1,55 @@
1
+ // src/Footer.jsx
2
+ import { useId } from "react";
3
+ import { jsx, jsxs } from "react/jsx-runtime";
4
+ var LEVELS = [1, 2, 3, 4, 5, 6];
5
+ var TAG = /^[a-z][a-z0-9-]*$/i;
6
+ var join = (...parts) => parts.filter(Boolean).join(" ");
7
+ var filled = (value) => value !== void 0 && value !== null && value !== false && value !== "";
8
+ var text = (value) => typeof value === "string" && value !== "";
9
+ var clean_links = (links) => Array.isArray(links) ? links.filter((link) => link && typeof link === "object" && text(link.href) && filled(link.label)) : [];
10
+ var clean_columns = (columns) => Array.isArray(columns) ? columns.filter((column) => column && typeof column === "object").map((column) => ({ ...column, links: clean_links(column.links) })).filter((column) => column.links.length > 0) : [];
11
+ function Column({ title, links, Heading }) {
12
+ const heading_id = useId();
13
+ const has_title = filled(title);
14
+ return /* @__PURE__ */ jsxs("div", { className: "c-footer__column", children: [
15
+ has_title ? /* @__PURE__ */ jsx(Heading, { className: "c-footer__title", id: heading_id, children: title }) : null,
16
+ /* @__PURE__ */ jsx("ul", { className: "c-footer__list", "aria-labelledby": has_title ? heading_id : void 0, children: links.map((link, index) => /* @__PURE__ */ jsx("li", { className: "c-footer__item", children: /* @__PURE__ */ jsx("a", { className: "c-footer__link", href: link.href, children: link.label }) }, `${link.href}-${index}`)) })
17
+ ] });
18
+ }
19
+ function Footer({
20
+ brand,
21
+ description,
22
+ columns,
23
+ title_level = 2,
24
+ nav_label,
25
+ legal,
26
+ social,
27
+ as = "div",
28
+ className = "",
29
+ ...rest
30
+ }) {
31
+ const Tag = typeof as === "string" && TAG.test(as) ? as : "div";
32
+ const level = Number(title_level);
33
+ const Heading = `h${LEVELS.includes(level) ? level : 2}`;
34
+ const safe_columns = clean_columns(columns);
35
+ const has_index = safe_columns.length > 0;
36
+ const has_brand = filled(brand) || filled(description);
37
+ const has_legal = filled(legal) || filled(social);
38
+ const Index = text(nav_label) ? "nav" : "div";
39
+ return /* @__PURE__ */ jsx(Tag, { ...rest, className: join("c-footer", className), children: /* @__PURE__ */ jsxs("div", { className: "c-footer__inner", children: [
40
+ has_brand || has_index ? /* @__PURE__ */ jsxs("div", { className: join("c-footer__top", has_brand && has_index && "c-footer__top--split"), children: [
41
+ has_brand ? /* @__PURE__ */ jsxs("div", { className: "c-footer__brand", children: [
42
+ filled(brand) ? /* @__PURE__ */ jsx("div", { className: "c-footer__logo", children: brand }) : null,
43
+ filled(description) ? /* @__PURE__ */ jsx("p", { className: "c-footer__description", children: description }) : null
44
+ ] }) : null,
45
+ has_index ? /* @__PURE__ */ jsx(Index, { className: "c-footer__nav", "aria-label": Index === "nav" ? nav_label : void 0, children: safe_columns.map((column, index) => /* @__PURE__ */ jsx(Column, { title: column.title, links: column.links, Heading }, `${column.id ?? ""}-${index}`)) }) : null
46
+ ] }) : null,
47
+ has_legal ? /* @__PURE__ */ jsxs("div", { className: "c-footer__legal-row", children: [
48
+ filled(legal) ? /* @__PURE__ */ jsx("div", { className: "c-footer__legal", children: legal }) : null,
49
+ filled(social) ? /* @__PURE__ */ jsx("div", { className: "c-footer__social", children: social }) : null
50
+ ] }) : null
51
+ ] }) });
52
+ }
53
+ export {
54
+ Footer
55
+ };
package/package.json CHANGED
@@ -1,6 +1,56 @@
1
1
  {
2
2
  "name": "@carissa-io/webkit-footer",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Carissa web kit: site footer as a labelled navigation index plus a legal row, as a React component with SCSS variables",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "files": [
8
+ "dist",
9
+ "scss",
10
+ "THIRD-PARTY-NOTICES"
11
+ ],
12
+ "exports": {
13
+ ".": "./dist/index.js",
14
+ "./scss": "./scss/index.scss",
15
+ "./scss/*": "./scss/*"
16
+ },
17
+ "sideEffects": false,
18
+ "scripts": {
19
+ "build": "esbuild src/index.js --bundle --packages=external --format=esm --jsx=automatic --outfile=dist/index.js",
20
+ "test": "vitest run",
21
+ "lint": "eslint .",
22
+ "prepublishOnly": "node -e \"if (!process.env.GITHUB_ACTIONS) { console.error('Refusing to publish outside GitHub Actions CI: commit and push to main instead.'); process.exit(1); }\" && npm run build"
23
+ },
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/carissa-io/webkit-footer.git"
27
+ },
28
+ "license": "UNLICENSED",
29
+ "engines": {
30
+ "node": ">=24"
31
+ },
32
+ "peerDependencies": {
33
+ "react": ">=18"
34
+ },
35
+ "devDependencies": {
36
+ "@eslint/js": "^10.0.1",
37
+ "@testing-library/jest-dom": "^7.0.1",
38
+ "@testing-library/react": "^16.3.3",
39
+ "@testing-library/user-event": "^14.6.7",
40
+ "@vitejs/plugin-react": "^6.1.1",
41
+ "esbuild": "^0.28.0",
42
+ "eslint": "^10.12.0",
43
+ "eslint-plugin-react-hooks": "^7.1.1",
44
+ "globals": "^17.13.0",
45
+ "jsdom": "^29.1.1",
46
+ "react": "^19.3.0",
47
+ "react-dom": "^19.3.0",
48
+ "sass-embedded": "^1.105.1",
49
+ "vite": "^8.3.2",
50
+ "vitest": "^4.1.11"
51
+ },
52
+ "publishConfig": {
53
+ "registry": "https://registry.npmjs.org",
54
+ "access": "public"
55
+ }
56
+ }
@@ -0,0 +1,118 @@
1
+ @use 'variables' as *;
2
+
3
+ .c-footer {
4
+ box-sizing: border-box;
5
+ padding-block: $c-footer-padding-block;
6
+ padding-inline: $c-footer-padding-inline;
7
+ font-family: $c-footer-font-family;
8
+ color: $c-footer-color;
9
+ background: $c-footer-background;
10
+
11
+ @media (min-width: $c-footer-padding-breakpoint) {
12
+ padding-inline: $c-footer-padding-inline-wide;
13
+ }
14
+ }
15
+
16
+ .c-footer__inner {
17
+ box-sizing: border-box;
18
+ max-width: $c-footer-max-width;
19
+ margin-inline: auto;
20
+ }
21
+
22
+ .c-footer__top {
23
+ display: grid;
24
+ gap: $c-footer-top-gap;
25
+ }
26
+
27
+ .c-footer__brand {
28
+ display: flex;
29
+ flex-direction: column;
30
+ gap: $c-footer-brand-gap;
31
+ min-width: 0;
32
+ }
33
+
34
+ .c-footer__logo {
35
+ min-width: 0;
36
+ }
37
+
38
+ .c-footer__description {
39
+ margin: 0;
40
+ max-width: $c-footer-description-max-width;
41
+ font-size: $c-footer-description-font-size;
42
+ line-height: $c-footer-description-line-height;
43
+ color: $c-footer-description-color;
44
+ }
45
+
46
+ .c-footer__nav {
47
+ display: grid;
48
+ grid-template-columns: repeat(auto-fit, minmax(min(100%, $c-footer-column-min-width), 1fr));
49
+ gap: $c-footer-column-gap;
50
+ min-width: 0;
51
+ }
52
+
53
+ .c-footer__column {
54
+ min-width: 0;
55
+ }
56
+
57
+ .c-footer__title {
58
+ margin: 0 0 $c-footer-title-margin-bottom;
59
+ font-size: $c-footer-title-font-size;
60
+ font-weight: $c-footer-title-font-weight;
61
+ letter-spacing: $c-footer-title-letter-spacing;
62
+ text-transform: $c-footer-title-text-transform;
63
+ color: $c-footer-title-color;
64
+ }
65
+
66
+ .c-footer__list {
67
+ display: flex;
68
+ flex-direction: column;
69
+ gap: $c-footer-list-gap;
70
+ margin: 0;
71
+ padding: 0;
72
+ list-style: none;
73
+ }
74
+
75
+ .c-footer__item {
76
+ margin: 0;
77
+ overflow-wrap: break-word;
78
+ }
79
+
80
+ .c-footer__link {
81
+ font-size: $c-footer-link-font-size;
82
+ color: $c-footer-link-color;
83
+ text-decoration: $c-footer-link-text-decoration;
84
+
85
+ &:hover {
86
+ text-decoration: $c-footer-link-hover-text-decoration;
87
+ }
88
+ }
89
+
90
+ .c-footer__legal-row {
91
+ display: flex;
92
+ flex-wrap: wrap;
93
+ align-items: center;
94
+ justify-content: space-between;
95
+ gap: $c-footer-legal-gap;
96
+ margin-top: $c-footer-legal-margin-top;
97
+ padding-top: $c-footer-legal-padding-top;
98
+ border-top: 1px solid $c-footer-rule-color;
99
+ font-size: $c-footer-legal-font-size;
100
+ color: $c-footer-legal-color;
101
+ }
102
+
103
+ .c-footer__legal,
104
+ .c-footer__social {
105
+ min-width: 0;
106
+ }
107
+
108
+ .c-footer__legal a,
109
+ .c-footer__social a {
110
+ color: $c-footer-legal-link-color;
111
+ text-decoration: $c-footer-legal-link-text-decoration;
112
+ }
113
+
114
+ @media (min-width: $c-footer-breakpoint) {
115
+ .c-footer__top--split {
116
+ grid-template-columns: $c-footer-top-columns;
117
+ }
118
+ }
@@ -0,0 +1,56 @@
1
+ // Every value is `!default`: set it before the first @use / through `with (...)` and it wins.
2
+ //
3
+ // @use '@carissa-io/webkit-footer/scss' with ($c-footer-column-min-width: 12rem, $c-footer-description-max-width: 36ch);
4
+ //
5
+ // The defaults are neutral: colours inherit the text colour, a transparent background, a rule in currentcolor, no shadow,
6
+ // no gradient, no motion. A theme gives the look (sizes from its type scale, `var(--c-*)` colours). The footer is the quiet
7
+ // end of a page: small type, modest padding, the rule and the whitespace do the work.
8
+
9
+ // Root and container
10
+ $c-footer-font-family: inherit !default;
11
+ $c-footer-color: inherit !default;
12
+ $c-footer-background: transparent !default;
13
+ $c-footer-max-width: 80rem !default; // the inner block, centred
14
+ $c-footer-padding-block: 3rem !default;
15
+ $c-footer-padding-inline: 1rem !default;
16
+ $c-footer-padding-inline-wide: 2rem !default;
17
+ $c-footer-padding-breakpoint: 40rem !default; // where the "wide" inline padding starts
18
+
19
+ // Layout
20
+ $c-footer-breakpoint: 40rem !default; // brand and index sit side by side from here; below it everything is stacked
21
+ $c-footer-top-columns: minmax(0, 1fr) minmax(0, 2fr) !default; // brand, index (side by side)
22
+ $c-footer-top-gap: 3rem !default; // brand to index
23
+ $c-footer-column-min-width: 10rem !default; // the index is an auto-fit grid of columns at least this wide
24
+ $c-footer-column-gap: 2rem !default; // between the columns of the index
25
+ $c-footer-brand-gap: 1rem !default; // brand to description
26
+
27
+ // Description (the measure stays short)
28
+ $c-footer-description-font-size: inherit !default;
29
+ $c-footer-description-line-height: 1.5 !default;
30
+ $c-footer-description-color: inherit !default;
31
+ $c-footer-description-max-width: 40ch !default;
32
+
33
+ // Column titles
34
+ $c-footer-title-font-size: inherit !default;
35
+ $c-footer-title-font-weight: 700 !default;
36
+ $c-footer-title-letter-spacing: normal !default;
37
+ $c-footer-title-text-transform: none !default;
38
+ $c-footer-title-color: inherit !default;
39
+ $c-footer-title-margin-bottom: 1rem !default;
40
+
41
+ // Links
42
+ $c-footer-list-gap: 0.625rem !default;
43
+ $c-footer-link-font-size: inherit !default;
44
+ $c-footer-link-color: inherit !default;
45
+ $c-footer-link-text-decoration: none !default;
46
+ $c-footer-link-hover-text-decoration: underline !default;
47
+
48
+ // Legal row
49
+ $c-footer-rule-color: currentcolor !default; // the 1px rule above the legal row
50
+ $c-footer-legal-margin-top: 3rem !default;
51
+ $c-footer-legal-padding-top: 1.5rem !default;
52
+ $c-footer-legal-gap: 0.75rem 1.5rem !default; // row gap, column gap (the row wraps)
53
+ $c-footer-legal-font-size: inherit !default;
54
+ $c-footer-legal-color: inherit !default;
55
+ $c-footer-legal-link-color: inherit !default; // links the site puts in the legal and social slots (they follow the row, not the browser's blue)
56
+ $c-footer-legal-link-text-decoration: underline !default;
@@ -0,0 +1,2 @@
1
+ @forward 'variables';
2
+ @use 'footer';