@scalewing/react 1.51.0 → 1.52.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/CHANGELOG.md +6 -0
- package/README.md +3 -2
- package/dist/components/SiteHeader.d.ts +6 -3
- package/dist/components/SiteHeader.js +8 -4
- package/dist/components/site-header/types.d.ts +16 -1
- package/dist/components/site-header/validate.d.ts +4 -3
- package/dist/components/site-header/validate.js +12 -3
- package/dist/css/css-site-footer.js +11 -1
- package/dist/css/css-site-header-anchors.d.ts +1 -0
- package/dist/css/css-site-header-anchors.js +22 -0
- package/dist/css/css-site-header-skip-link.d.ts +1 -0
- package/dist/css/css-site-header-skip-link.js +49 -0
- package/dist/css/css-site-header.js +26 -11
- package/dist/css/site-header-geometry.d.ts +21 -0
- package/dist/css/site-header-geometry.js +28 -0
- package/dist/index.d.ts +1 -1
- package/dist/styles.css +61 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# @scalewing/react
|
|
2
2
|
|
|
3
|
+
## 1.52.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 03f2e78: Site chrome follow-ups from Teisoro's marketing-site UX review. `SiteHeader` takes an optional `skipLink` (`{ label, href }`, exported as `SiteHeaderSkipLink`): a "skip to content" link that is the header's first focusable element, visually hidden until it has focus, then a pill on the surface with the accent ring laid over the bar's start (`sw-site-header-skip-link`); a blank label or an `href` that is not a `#fragment` throws a `RangeError`. While a sticky `SiteHeader` is on the page, the document's `scroll-padding-top` clears the bar (56 px, 72 px from `lg`), its hairline and a small gap, so `#fragment` links, `:target` and `scrollIntoView` land below the bar; a header with `sticky={false}` changes nothing. Below `lg`, the bar's `compactActions` (now in a `sw-site-header-compact-actions` box with `display: contents`) step aside while the menu is open, since the menu holds its own `menuActions`, leaving the brand and the close button. On a coarse pointer, a plain link that is all of a `SiteFooter` bottom-row slot (`legal` or `end`) gets the 44 px touch target, as the group links have. No new tokens or dependencies. Request: `docs/requests/teisoro-site-chrome-follow-ups.md`.
|
|
8
|
+
|
|
3
9
|
## 1.51.0
|
|
4
10
|
|
|
5
11
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -1079,10 +1079,11 @@ const { headings, omissions } = inspectMarkdown(page.body, {
|
|
|
1079
1079
|
</Band>
|
|
1080
1080
|
```
|
|
1081
1081
|
|
|
1082
|
-
`SiteHeader` is a public site's header. Supply `brand` (such as a logo linked to the home page), `navLabel` and `items` (`{ id, label, href, current? }`, `current` as SectionNav's: `page`, `location` or `true` sets `aria-current`), `labels` (`openMenu`, `closeMenu`) and the decorative `menuIcon` and `closeIcon` glyphs. From `lg` the bar shows the brand, the destinations as a row of links (the current one at label weight over a bar in the words' colour, a thick underline in forced colours) and `actions` at its end, such as a language switch, Sign in (`Box as="a" link="quiet"`) and a `ButtonLink`. Below `lg` it shows the brand, `compactActions` and a menu button that opens, under the bar over the rest of the window, the destinations as large rows (the current one in the accent) and `menuActions` at the panel's foot, such as full-width `ButtonLink`s. The menu is a native `details`, so it opens and closes by touch, click and keyboard with no script, as a page rendered on the server needs; its `summary` is the button, named `openMenu` or `closeMenu` by its state. Script adds Escape (focus back on the button), and closes it when a link in it is chosen, focus leaves it, a press lands outside it, or the window widens to `lg`; nothing traps focus. A page rendered on the server has the no-script menu until it hydrates; a site that never hydrates keeps it, which still opens and closes. The bar is 56 px tall below `lg` and 72 px from it, on the solid surface with a hairline under it; `sticky` (default `true`) keeps it at the window's top. No items, blank or repeated ids, blank labels or hrefs,
|
|
1082
|
+
`SiteHeader` is a public site's header. Supply `brand` (such as a logo linked to the home page), `navLabel` and `items` (`{ id, label, href, current? }`, `current` as SectionNav's: `page`, `location` or `true` sets `aria-current`), `labels` (`openMenu`, `closeMenu`) and the decorative `menuIcon` and `closeIcon` glyphs. From `lg` the bar shows the brand, the destinations as a row of links (the current one at label weight over a bar in the words' colour, a thick underline in forced colours) and `actions` at its end, such as a language switch, Sign in (`Box as="a" link="quiet"`) and a `ButtonLink`. Below `lg` it shows the brand, `compactActions` and a menu button that opens, under the bar over the rest of the window, the destinations as large rows (the current one in the accent) and `menuActions` at the panel's foot, such as full-width `ButtonLink`s. The menu is a native `details`, so it opens and closes by touch, click and keyboard with no script, as a page rendered on the server needs; its `summary` is the button, named `openMenu` or `closeMenu` by its state. Script adds Escape (focus back on the button), and closes it when a link in it is chosen, focus leaves it, a press lands outside it, or the window widens to `lg`; nothing traps focus. A page rendered on the server has the no-script menu until it hydrates; a site that never hydrates keeps it, which still opens and closes. While the menu is open the bar's `compactActions` step aside (the menu holds its own `menuActions`), so the bar shows only the brand and the close button. The bar is 56 px tall below `lg` and 72 px from it, on the solid surface with a hairline under it; `sticky` (default `true`) keeps it at the window's top. While a sticky header is on the page the document's `scroll-padding-top` clears the bar, its hairline and a small gap, so a `#fragment` link, `:target` and `scrollIntoView` land below the bar instead of under it; a header with `sticky={false}` leaves the scroll padding alone. An optional `skipLink` (`{ label, href }`, such as `{ label: 'Skip to content', href: '#main' }`) is the header's first focusable element: visually hidden until it has focus, then a pill on the surface with the focus ring over the bar's start. Give the target (usually `main`) the matching `id`, and `tabIndex={-1}` so every browser moves focus there with the jump. No items, blank or repeated ids, blank labels or hrefs, a blank `navLabel` or menu label, or a skip link with a blank label or an `href` that is not a `#fragment` throw a `RangeError`.
|
|
1083
1083
|
|
|
1084
1084
|
```tsx
|
|
1085
1085
|
<SiteHeader
|
|
1086
|
+
skipLink={{ label: 'Skip to content', href: '#main' }}
|
|
1086
1087
|
brand={<a href="/">Wetland Trust</a>}
|
|
1087
1088
|
navLabel="Main"
|
|
1088
1089
|
items={[{ id: 'marsh', label: 'Marsh', href: '/marsh', current: 'page' }]}
|
|
@@ -1132,7 +1133,7 @@ const { headings, omissions } = inspectMarkdown(page.body, {
|
|
|
1132
1133
|
</ScreenPreview>
|
|
1133
1134
|
```
|
|
1134
1135
|
|
|
1135
|
-
`SiteFooter` is a public site's footer on the dark inverse band (`Band tone="inverse"`, so a brand moves it with a theme overlay of `inverse`). Supply `brand` (such as a logo for a dark background), an optional `tagline`, and `groups`: each `{ id, label, links }` is a `nav` named by its label, which shows above its links (hidden from assistive technology, since the `nav` already carries it). Each link is `{ id, label, href, current?, lang?, hrefLang? }`; `lang` and `hrefLang` pass through to the link unchecked; `current` sets `aria-current="page"` and draws it in the band's words at label weight, the others in its muted words, underlined on hover and focus. `legal` and `end` fill the bottom row past a hairline, such as the copyright and a language switch. Below `md` the brand takes the full width and the groups two columns; from `md` the brand and the groups share one spread row. Links get the 44 px target on a coarse pointer. Put it last in the page's `body` (outside a `section` or `article`) so it is the page's `contentinfo` landmark. No groups, a group with no links, blank or repeated ids, blank labels or a blank `href` throw a `RangeError`.
|
|
1136
|
+
`SiteFooter` is a public site's footer on the dark inverse band (`Band tone="inverse"`, so a brand moves it with a theme overlay of `inverse`). Supply `brand` (such as a logo for a dark background), an optional `tagline`, and `groups`: each `{ id, label, links }` is a `nav` named by its label, which shows above its links (hidden from assistive technology, since the `nav` already carries it). Each link is `{ id, label, href, current?, lang?, hrefLang? }`; `lang` and `hrefLang` pass through to the link unchecked; `current` sets `aria-current="page"` and draws it in the band's words at label weight, the others in its muted words, underlined on hover and focus. `legal` and `end` fill the bottom row past a hairline, such as the copyright and a language switch; a plain link that is all of `legal` or `end` gets the 44 px target on a coarse pointer too (a link inside running legal words stays inline). Below `md` the brand takes the full width and the groups two columns; from `md` the brand and the groups share one spread row. Links get the 44 px target on a coarse pointer. Put it last in the page's `body` (outside a `section` or `article`) so it is the page's `contentinfo` landmark. No groups, a group with no links, blank or repeated ids, blank labels or a blank `href` throw a `RangeError`.
|
|
1136
1137
|
|
|
1137
1138
|
```tsx
|
|
1138
1139
|
<SiteFooter
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type SiteHeaderProps } from './site-header/types.js';
|
|
2
|
-
export type { SiteHeaderItem, SiteHeaderLabels, SiteHeaderProps, } from './site-header/types.js';
|
|
2
|
+
export type { SiteHeaderItem, SiteHeaderLabels, SiteHeaderProps, SiteHeaderSkipLink, } from './site-header/types.js';
|
|
3
3
|
/**
|
|
4
4
|
* A public site's header: the brand at the start; from `lg` the main
|
|
5
5
|
* destinations as a row of links (the current one marked by weight and a
|
|
@@ -9,6 +9,9 @@ export type { SiteHeaderItem, SiteHeaderLabels, SiteHeaderProps, } from './site-
|
|
|
9
9
|
* it opens and closes by touch, click and keyboard with no script, as a
|
|
10
10
|
* page rendered on the server needs; script adds Escape (focus back on the
|
|
11
11
|
* button) and closes it when a link is chosen or focus leaves it. It traps
|
|
12
|
-
* no focus.
|
|
12
|
+
* no focus. While the menu is open the compact actions step aside, so the
|
|
13
|
+
* bar shows only the brand and the close button. An optional skip link
|
|
14
|
+
* comes first, hidden until it has focus. Invalid items or words throw a
|
|
15
|
+
* `RangeError`.
|
|
13
16
|
*/
|
|
14
|
-
export declare function SiteHeader({ actions, brand, closeIcon, compactActions, items, labels, menuActions, menuIcon, navLabel, sticky, }: SiteHeaderProps): import("react").JSX.Element;
|
|
17
|
+
export declare function SiteHeader({ actions, brand, closeIcon, compactActions, items, labels, menuActions, menuIcon, navLabel, skipLink, sticky, }: SiteHeaderProps): import("react").JSX.Element;
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { useRef } from 'react';
|
|
4
4
|
import { cx } from '../class-names.js';
|
|
5
|
+
import { tabStop } from '../tab-stop.js';
|
|
5
6
|
import { SiteHeaderLinks } from './site-header/SiteHeaderLinks.js';
|
|
6
7
|
import { useMenuDismiss } from './site-header/use-menu-dismiss.js';
|
|
7
8
|
import { assertSiteHeaderProps } from './site-header/validate.js';
|
|
@@ -14,11 +15,14 @@ import { assertSiteHeaderProps } from './site-header/validate.js';
|
|
|
14
15
|
* it opens and closes by touch, click and keyboard with no script, as a
|
|
15
16
|
* page rendered on the server needs; script adds Escape (focus back on the
|
|
16
17
|
* button) and closes it when a link is chosen or focus leaves it. It traps
|
|
17
|
-
* no focus.
|
|
18
|
+
* no focus. While the menu is open the compact actions step aside, so the
|
|
19
|
+
* bar shows only the brand and the close button. An optional skip link
|
|
20
|
+
* comes first, hidden until it has focus. Invalid items or words throw a
|
|
21
|
+
* `RangeError`.
|
|
18
22
|
*/
|
|
19
|
-
export function SiteHeader({ actions, brand, closeIcon, compactActions, items, labels, menuActions, menuIcon, navLabel, sticky = true, }) {
|
|
20
|
-
assertSiteHeaderProps({ items, labels, navLabel });
|
|
23
|
+
export function SiteHeader({ actions, brand, closeIcon, compactActions, items, labels, menuActions, menuIcon, navLabel, skipLink, sticky = true, }) {
|
|
24
|
+
assertSiteHeaderProps({ items, labels, navLabel, skipLink });
|
|
21
25
|
const menuRef = useRef(null);
|
|
22
26
|
useMenuDismiss(menuRef);
|
|
23
|
-
return (
|
|
27
|
+
return (_jsxs("header", { className: cx('sw-site-header', sticky && 'sw-site-header-sticky'), children: [skipLink ? (_jsx("a", { className: "sw-site-header-skip-link", href: skipLink.href, tabIndex: tabStop, children: skipLink.label })) : null, _jsxs("div", { className: "sw-site-header-bar", children: [_jsx("div", { className: "sw-site-header-brand", children: brand }), _jsx(SiteHeaderLinks, { className: "sw-site-header-nav", items: items, navLabel: navLabel }), actions ? (_jsx("div", { className: "sw-site-header-actions", children: actions })) : null, _jsxs("div", { className: "sw-site-header-compact", children: [compactActions ? (_jsx("div", { className: "sw-site-header-compact-actions", children: compactActions })) : null, _jsxs("details", { className: "sw-site-header-menu", ref: menuRef, children: [_jsxs("summary", { className: "sw-site-header-menu-button", children: [_jsxs("span", { className: "sw-site-header-menu-open", children: [_jsx("span", { "aria-hidden": "true", className: "sw-site-header-glyph", children: menuIcon }), _jsx("span", { className: "sw-sr-only", children: labels.openMenu })] }), _jsxs("span", { className: "sw-site-header-menu-close", children: [_jsx("span", { "aria-hidden": "true", className: "sw-site-header-glyph", children: closeIcon }), _jsx("span", { className: "sw-sr-only", children: labels.closeMenu })] })] }), _jsxs("div", { className: "sw-site-header-panel", children: [_jsx(SiteHeaderLinks, { className: "sw-site-header-menu-nav", items: items, navLabel: navLabel }), menuActions ? (_jsx("div", { className: "sw-site-header-menu-actions", children: menuActions })) : null] })] })] })] })] }));
|
|
24
28
|
}
|
|
@@ -15,6 +15,16 @@ export type SiteHeaderLabels = {
|
|
|
15
15
|
/** The open menu button's name, such as "Close the menu". */
|
|
16
16
|
closeMenu: string;
|
|
17
17
|
};
|
|
18
|
+
/**
|
|
19
|
+
* A "skip to content" link, the header's first focusable element: hidden
|
|
20
|
+
* until it has focus, then shown at the bar's start.
|
|
21
|
+
*/
|
|
22
|
+
export type SiteHeaderSkipLink = {
|
|
23
|
+
/** The link's words, such as "Skip to content". */
|
|
24
|
+
label: string;
|
|
25
|
+
/** The main content's fragment, such as `#main`. */
|
|
26
|
+
href: string;
|
|
27
|
+
};
|
|
18
28
|
export type SiteHeaderProps = {
|
|
19
29
|
/** The site's mark at the start, such as a logo linked to the home page. */
|
|
20
30
|
brand: ReactNode;
|
|
@@ -24,7 +34,10 @@ export type SiteHeaderProps = {
|
|
|
24
34
|
items: readonly SiteHeaderItem[];
|
|
25
35
|
/** The bar's end from `lg`, such as a language switch, Sign in and a ButtonLink. */
|
|
26
36
|
actions?: ReactNode;
|
|
27
|
-
/**
|
|
37
|
+
/**
|
|
38
|
+
* Beside the menu button below `lg`, such as the language switch. They
|
|
39
|
+
* step aside while the menu is open, which holds its own `menuActions`.
|
|
40
|
+
*/
|
|
28
41
|
compactActions?: ReactNode;
|
|
29
42
|
/** In the open menu under its links, such as full-width ButtonLinks. */
|
|
30
43
|
menuActions?: ReactNode;
|
|
@@ -35,4 +48,6 @@ export type SiteHeaderProps = {
|
|
|
35
48
|
closeIcon: ReactNode;
|
|
36
49
|
/** Whether the bar sticks to the window's top as the page scrolls (default `true`). */
|
|
37
50
|
sticky?: boolean;
|
|
51
|
+
/** A "skip to content" link before everything else in the header. */
|
|
52
|
+
skipLink?: SiteHeaderSkipLink;
|
|
38
53
|
};
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { type SiteHeaderProps } from './types.js';
|
|
2
2
|
/**
|
|
3
3
|
* Fails closed on the header's words and destinations: no items, blank or
|
|
4
|
-
* repeated ids, blank labels or hrefs,
|
|
5
|
-
* button name
|
|
4
|
+
* repeated ids, blank labels or hrefs, a blank navigation name or menu
|
|
5
|
+
* button name, and a skip link with blank words or an address that is not
|
|
6
|
+
* a fragment on the page.
|
|
6
7
|
*/
|
|
7
|
-
export declare function assertSiteHeaderProps({ items, labels, navLabel, }: Pick<SiteHeaderProps, 'items' | 'labels' | 'navLabel'>): void;
|
|
8
|
+
export declare function assertSiteHeaderProps({ items, labels, navLabel, skipLink, }: Pick<SiteHeaderProps, 'items' | 'labels' | 'navLabel' | 'skipLink'>): void;
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { assertNavItems } from '../nav-items.js';
|
|
2
2
|
/**
|
|
3
3
|
* Fails closed on the header's words and destinations: no items, blank or
|
|
4
|
-
* repeated ids, blank labels or hrefs,
|
|
5
|
-
* button name
|
|
4
|
+
* repeated ids, blank labels or hrefs, a blank navigation name or menu
|
|
5
|
+
* button name, and a skip link with blank words or an address that is not
|
|
6
|
+
* a fragment on the page.
|
|
6
7
|
*/
|
|
7
|
-
export function assertSiteHeaderProps({ items, labels, navLabel, }) {
|
|
8
|
+
export function assertSiteHeaderProps({ items, labels, navLabel, skipLink, }) {
|
|
8
9
|
if (items.length === 0)
|
|
9
10
|
throw new RangeError('SiteHeader needs at least one item');
|
|
10
11
|
assertNavItems('SiteHeader', items);
|
|
@@ -14,4 +15,12 @@ export function assertSiteHeaderProps({ items, labels, navLabel, }) {
|
|
|
14
15
|
throw new RangeError('SiteHeader needs a nonempty navLabel');
|
|
15
16
|
if (!labels.openMenu.trim() || !labels.closeMenu.trim())
|
|
16
17
|
throw new RangeError('SiteHeader labels need nonempty openMenu and closeMenu');
|
|
18
|
+
if (skipLink)
|
|
19
|
+
assertSkipLink(skipLink);
|
|
20
|
+
}
|
|
21
|
+
function assertSkipLink({ href, label }) {
|
|
22
|
+
if (!label.trim())
|
|
23
|
+
throw new RangeError('SiteHeader skipLink needs a nonempty label');
|
|
24
|
+
if (!/^#\S+$/.test(href))
|
|
25
|
+
throw new RangeError('SiteHeader skipLink href must be a fragment such as #main');
|
|
17
26
|
}
|
|
@@ -21,7 +21,10 @@ function type(variant, weight = variant.fontWeight) {
|
|
|
21
21
|
* win by coming after it (`cssSiteFooterClasses` follows `cssBandClasses`;
|
|
22
22
|
* a test holds the order).
|
|
23
23
|
* Below `md` the brand spans two columns of groups; from `md` the brand
|
|
24
|
-
* and the groups share one wrapping row, spread apart.
|
|
24
|
+
* and the groups share one wrapping row, spread apart. On a coarse pointer
|
|
25
|
+
* the group links and a link that is all of a bottom-row slot (`legal` or
|
|
26
|
+
* `end`, such as a language switch) are the 44 px touch target; a link
|
|
27
|
+
* inside running legal words stays inline.
|
|
25
28
|
*/
|
|
26
29
|
export function cssSiteFooterClasses() {
|
|
27
30
|
return `.sw-site-footer-top {
|
|
@@ -120,6 +123,13 @@ export function cssSiteFooterClasses() {
|
|
|
120
123
|
min-height: ${touchTarget};
|
|
121
124
|
}
|
|
122
125
|
|
|
126
|
+
[data-theme] .sw-band-inverse :is(.sw-site-footer-legal, .sw-site-footer-end) > a:not(.sw-button) {
|
|
127
|
+
align-items: center;
|
|
128
|
+
display: inline-flex;
|
|
129
|
+
justify-content: center;
|
|
130
|
+
min-height: ${touchTarget};
|
|
131
|
+
}
|
|
132
|
+
|
|
123
133
|
.sw-site-footer-links {
|
|
124
134
|
gap: 0;
|
|
125
135
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function cssSiteHeaderAnchorRules(): string;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { siteHeaderCompactHeight, siteHeaderWideHeight, siteHeaderWideQuery, } from './site-header-geometry.js';
|
|
2
|
+
/** The room an in-page jump leaves above its target: the bar, its hairline and a gap. */
|
|
3
|
+
function scrollPadding(barHeight) {
|
|
4
|
+
return `calc(${barHeight} + 1px + var(--sw-space-3))`;
|
|
5
|
+
}
|
|
6
|
+
/*
|
|
7
|
+
* While a sticky SiteHeader is on the page, the document's scroll padding
|
|
8
|
+
* clears the bar (its height at each width, its hairline) and a small gap,
|
|
9
|
+
* so a #fragment link, :target and scrollIntoView land below the bar
|
|
10
|
+
* instead of under it. A header in the page flow changes nothing.
|
|
11
|
+
*/
|
|
12
|
+
export function cssSiteHeaderAnchorRules() {
|
|
13
|
+
return `html:has(.sw-site-header-sticky) {
|
|
14
|
+
scroll-padding-top: ${scrollPadding(siteHeaderCompactHeight)};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
@media ${siteHeaderWideQuery} {
|
|
18
|
+
html:has(.sw-site-header-sticky) {
|
|
19
|
+
scroll-padding-top: ${scrollPadding(siteHeaderWideHeight)};
|
|
20
|
+
}
|
|
21
|
+
}`;
|
|
22
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function cssSiteHeaderSkipLinkRules(): string;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { typographyVariants } from '@scalewing/tokens';
|
|
2
|
+
import { visuallyHiddenDeclarations } from './css-utilities.js';
|
|
3
|
+
import { siteHeaderContentStart, siteHeaderGutter, siteHeaderGutterQuery, } from './site-header-geometry.js';
|
|
4
|
+
const label = typographyVariants.label;
|
|
5
|
+
/*
|
|
6
|
+
* The header's skip link, its first focusable element: visually hidden
|
|
7
|
+
* until it has focus, then a pill on the surface in the text colour with
|
|
8
|
+
* the accent ring, laid over the bar's start (centred in its height and
|
|
9
|
+
* lined up with the brand), the control height tall only then. Positioned,
|
|
10
|
+
* it paints over the bar's content, which is not, so it needs no layer of
|
|
11
|
+
* its own.
|
|
12
|
+
*/
|
|
13
|
+
export function cssSiteHeaderSkipLinkRules() {
|
|
14
|
+
return `.sw-site-header-skip-link {
|
|
15
|
+
align-items: center;
|
|
16
|
+
background: var(--sw-color-surface);
|
|
17
|
+
border-radius: var(--sw-radius-pill);
|
|
18
|
+
box-sizing: border-box;
|
|
19
|
+
display: inline-flex;
|
|
20
|
+
font-family: var(--sw-font-sans);
|
|
21
|
+
font-size: ${label.fontSize}px;
|
|
22
|
+
font-weight: ${label.fontWeight};
|
|
23
|
+
inset-block-start: 50%;
|
|
24
|
+
inset-inline-start: ${siteHeaderContentStart(siteHeaderGutter.compact)};
|
|
25
|
+
letter-spacing: ${label.letterSpacing}px;
|
|
26
|
+
line-height: ${label.lineHeight}px;
|
|
27
|
+
padding-inline: var(--sw-space-4);
|
|
28
|
+
position: absolute;
|
|
29
|
+
transform: translateY(-50%);
|
|
30
|
+
white-space: nowrap;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
[data-theme] .sw-site-header-skip-link {
|
|
34
|
+
color: var(--sw-color-text);
|
|
35
|
+
text-decoration: none;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
[data-theme] .sw-site-header-skip-link:focus {
|
|
39
|
+
min-height: var(--sw-control-md-min-height);
|
|
40
|
+
outline: var(--sw-focus-ring-width) solid var(--sw-color-accent);
|
|
41
|
+
outline-offset: calc(-1 * var(--sw-focus-ring-width));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.sw-site-header-skip-link:not(:focus) { ${visuallyHiddenDeclarations} }
|
|
45
|
+
|
|
46
|
+
@media ${siteHeaderGutterQuery} {
|
|
47
|
+
.sw-site-header-skip-link { inset-inline-start: ${siteHeaderContentStart(siteHeaderGutter.wide)}; }
|
|
48
|
+
}`;
|
|
49
|
+
}
|
|
@@ -1,19 +1,18 @@
|
|
|
1
1
|
import { typographyVariants } from '@scalewing/tokens';
|
|
2
|
-
import {
|
|
2
|
+
import { cssSiteHeaderAnchorRules } from './css-site-header-anchors.js';
|
|
3
|
+
import { cssSiteHeaderSkipLinkRules } from './css-site-header-skip-link.js';
|
|
3
4
|
import { quietFill } from './quiet-fill.js';
|
|
5
|
+
import { siteHeaderBarMaxWidth, siteHeaderCompactHeight as compactHeight, siteHeaderGutter, siteHeaderGutterQuery, siteHeaderWideHeight as wideHeight, siteHeaderWideQuery as wide, } from './site-header-geometry.js';
|
|
4
6
|
import { zIndex } from './stacking.js';
|
|
5
7
|
const label = typographyVariants.label;
|
|
6
8
|
const title = typographyVariants.title;
|
|
7
|
-
/** The bar's height: 56 px on a phone, 72 px from `lg`. */
|
|
8
|
-
const compactHeight = 'calc(var(--sw-control-md-min-height) + var(--sw-space-3))';
|
|
9
|
-
const wideHeight = 'calc(var(--sw-control-md-min-height) + var(--sw-space-5) + var(--sw-space-1))';
|
|
10
|
-
const wide = breakpointQuery('from', 'lg');
|
|
11
9
|
/*
|
|
12
10
|
* The bar: the solid surface with a hairline under it, its content in the
|
|
13
11
|
* page container with the page gutter. Below `lg` it holds the brand, the
|
|
14
12
|
* compact actions and the menu button; from `lg` the brand, the
|
|
15
13
|
* destinations (the current one at label weight over a bar in the words'
|
|
16
|
-
* colour, as tall as the bar) and the actions, spread apart.
|
|
14
|
+
* colour, as tall as the bar) and the actions, spread apart. The compact
|
|
15
|
+
* actions' box adds no layout of its own (`display: contents`).
|
|
17
16
|
*/
|
|
18
17
|
function barRules() {
|
|
19
18
|
return `.sw-site-header {
|
|
@@ -38,9 +37,9 @@ function barRules() {
|
|
|
38
37
|
gap: var(--sw-space-5);
|
|
39
38
|
justify-content: space-between;
|
|
40
39
|
margin-inline: auto;
|
|
41
|
-
max-width:
|
|
40
|
+
max-width: ${siteHeaderBarMaxWidth};
|
|
42
41
|
min-height: var(--sw-site-header-height);
|
|
43
|
-
padding-inline:
|
|
42
|
+
padding-inline: ${siteHeaderGutter.compact};
|
|
44
43
|
}
|
|
45
44
|
|
|
46
45
|
.sw-site-header-brand {
|
|
@@ -60,6 +59,10 @@ function barRules() {
|
|
|
60
59
|
gap: var(--sw-space-1);
|
|
61
60
|
}
|
|
62
61
|
|
|
62
|
+
.sw-site-header-compact-actions {
|
|
63
|
+
display: contents;
|
|
64
|
+
}
|
|
65
|
+
|
|
63
66
|
.sw-site-header-list {
|
|
64
67
|
list-style: none;
|
|
65
68
|
margin: 0;
|
|
@@ -101,8 +104,8 @@ function barRules() {
|
|
|
101
104
|
gap: var(--sw-space-4);
|
|
102
105
|
}
|
|
103
106
|
|
|
104
|
-
@media ${
|
|
105
|
-
.sw-site-header-bar { padding-inline:
|
|
107
|
+
@media ${siteHeaderGutterQuery} {
|
|
108
|
+
.sw-site-header-bar { padding-inline: ${siteHeaderGutter.wide}; }
|
|
106
109
|
}
|
|
107
110
|
|
|
108
111
|
@media ${wide} {
|
|
@@ -118,13 +121,19 @@ function barRules() {
|
|
|
118
121
|
* open, the other pair hidden so only one is read), and its panel lies
|
|
119
122
|
* under the bar over the rest of the window, the destinations as large
|
|
120
123
|
* rows with hairlines and the menu's actions at the panel's foot. It
|
|
121
|
-
* scrolls on its own when the window is short.
|
|
124
|
+
* scrolls on its own when the window is short. While it is open the bar's
|
|
125
|
+
* compact actions step aside, since the menu holds its own actions, so the
|
|
126
|
+
* bar shows only the brand and the close button.
|
|
122
127
|
*/
|
|
123
128
|
function menuRules() {
|
|
124
129
|
return `.sw-site-header-menu > summary {
|
|
125
130
|
list-style: none;
|
|
126
131
|
}
|
|
127
132
|
|
|
133
|
+
.sw-site-header-compact:has(> .sw-site-header-menu[open]) > .sw-site-header-compact-actions {
|
|
134
|
+
display: none;
|
|
135
|
+
}
|
|
136
|
+
|
|
128
137
|
.sw-site-header-menu > summary::-webkit-details-marker {
|
|
129
138
|
display: none;
|
|
130
139
|
}
|
|
@@ -214,6 +223,10 @@ export function cssSiteHeaderClasses() {
|
|
|
214
223
|
|
|
215
224
|
${menuRules()}
|
|
216
225
|
|
|
226
|
+
${cssSiteHeaderSkipLinkRules()}
|
|
227
|
+
|
|
228
|
+
${cssSiteHeaderAnchorRules()}
|
|
229
|
+
|
|
217
230
|
/*
|
|
218
231
|
* Forced colours drop box shadows, so the current destination's bar is a
|
|
219
232
|
* thick underline there.
|
|
@@ -248,6 +261,8 @@ export function siteHeaderClassCatalog() {
|
|
|
248
261
|
'sw-site-header-nav',
|
|
249
262
|
'sw-site-header-actions',
|
|
250
263
|
'sw-site-header-compact',
|
|
264
|
+
'sw-site-header-compact-actions',
|
|
265
|
+
'sw-site-header-skip-link',
|
|
251
266
|
'sw-site-header-list',
|
|
252
267
|
'sw-site-header-link',
|
|
253
268
|
'sw-site-header-menu',
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** The bar's height below `lg`: 56 px. */
|
|
2
|
+
export declare const siteHeaderCompactHeight = "calc(var(--sw-control-md-min-height) + var(--sw-space-3))";
|
|
3
|
+
/** The bar's height from `lg`: 72 px. */
|
|
4
|
+
export declare const siteHeaderWideHeight = "calc(var(--sw-control-md-min-height) + var(--sw-space-5) + var(--sw-space-1))";
|
|
5
|
+
/** The bar's content: the page container plus the page gutter each side. */
|
|
6
|
+
export declare const siteHeaderBarMaxWidth = "calc(var(--sw-container-max) + 2 * var(--sw-space-5))";
|
|
7
|
+
/** From here the bar is the wide one with its row of destinations. */
|
|
8
|
+
export declare const siteHeaderWideQuery: string;
|
|
9
|
+
/** From here the bar's gutter is the wider one. */
|
|
10
|
+
export declare const siteHeaderGutterQuery: string;
|
|
11
|
+
/** The bar's gutter below `md` (`compact`) and from it (`wide`). */
|
|
12
|
+
export declare const siteHeaderGutter: {
|
|
13
|
+
readonly compact: "var(--sw-space-4)";
|
|
14
|
+
readonly wide: "var(--sw-space-5)";
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Where the bar's content starts, measured from the header's start: the
|
|
18
|
+
* centred bar's margin (none on a window narrower than the bar) plus its
|
|
19
|
+
* gutter, so something laid over the bar lines up with the brand.
|
|
20
|
+
*/
|
|
21
|
+
export declare function siteHeaderContentStart(gutter: string): string;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { breakpointQuery } from './breakpoints.js';
|
|
2
|
+
/*
|
|
3
|
+
* The site header's measures, shared by the bar, its skip link and the
|
|
4
|
+
* document's scroll padding so all three agree on where the bar ends.
|
|
5
|
+
*/
|
|
6
|
+
/** The bar's height below `lg`: 56 px. */
|
|
7
|
+
export const siteHeaderCompactHeight = 'calc(var(--sw-control-md-min-height) + var(--sw-space-3))';
|
|
8
|
+
/** The bar's height from `lg`: 72 px. */
|
|
9
|
+
export const siteHeaderWideHeight = 'calc(var(--sw-control-md-min-height) + var(--sw-space-5) + var(--sw-space-1))';
|
|
10
|
+
/** The bar's content: the page container plus the page gutter each side. */
|
|
11
|
+
export const siteHeaderBarMaxWidth = 'calc(var(--sw-container-max) + 2 * var(--sw-space-5))';
|
|
12
|
+
/** From here the bar is the wide one with its row of destinations. */
|
|
13
|
+
export const siteHeaderWideQuery = breakpointQuery('from', 'lg');
|
|
14
|
+
/** From here the bar's gutter is the wider one. */
|
|
15
|
+
export const siteHeaderGutterQuery = breakpointQuery('from', 'md');
|
|
16
|
+
/** The bar's gutter below `md` (`compact`) and from it (`wide`). */
|
|
17
|
+
export const siteHeaderGutter = {
|
|
18
|
+
compact: 'var(--sw-space-4)',
|
|
19
|
+
wide: 'var(--sw-space-5)',
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Where the bar's content starts, measured from the header's start: the
|
|
23
|
+
* centred bar's margin (none on a window narrower than the bar) plus its
|
|
24
|
+
* gutter, so something laid over the bar lines up with the brand.
|
|
25
|
+
*/
|
|
26
|
+
export function siteHeaderContentStart(gutter) {
|
|
27
|
+
return `calc(max(0px, (100% - ${siteHeaderBarMaxWidth}) / 2) + ${gutter})`;
|
|
28
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -81,7 +81,7 @@ export { breakpointQuery } from './css/breakpoints.js';
|
|
|
81
81
|
export { Band, bandElements, bandTones, bandWidths, } from './components/Band.js';
|
|
82
82
|
export type { BandElement, BandProps, BandTone, BandWidth, } from './components/Band.js';
|
|
83
83
|
export { SiteHeader } from './components/SiteHeader.js';
|
|
84
|
-
export type { SiteHeaderItem, SiteHeaderLabels, SiteHeaderProps, } from './components/SiteHeader.js';
|
|
84
|
+
export type { SiteHeaderItem, SiteHeaderLabels, SiteHeaderProps, SiteHeaderSkipLink, } from './components/SiteHeader.js';
|
|
85
85
|
export { ScreenPreview, screenPreviewFrames, } from './components/ScreenPreview.js';
|
|
86
86
|
export type { ScreenPreviewFrame, ScreenPreviewProps, } from './components/ScreenPreview.js';
|
|
87
87
|
export { PreviewGroup } from './components/screen-preview/PreviewGroup.js';
|
package/dist/styles.css
CHANGED
|
@@ -4939,6 +4939,13 @@ body {
|
|
|
4939
4939
|
min-height: var(--sw-control-md-min-height);
|
|
4940
4940
|
}
|
|
4941
4941
|
|
|
4942
|
+
[data-theme] .sw-band-inverse :is(.sw-site-footer-legal, .sw-site-footer-end) > a:not(.sw-button) {
|
|
4943
|
+
align-items: center;
|
|
4944
|
+
display: inline-flex;
|
|
4945
|
+
justify-content: center;
|
|
4946
|
+
min-height: var(--sw-control-md-min-height);
|
|
4947
|
+
}
|
|
4948
|
+
|
|
4942
4949
|
.sw-site-footer-links {
|
|
4943
4950
|
gap: 0;
|
|
4944
4951
|
}
|
|
@@ -4998,6 +5005,10 @@ body {
|
|
|
4998
5005
|
gap: var(--sw-space-1);
|
|
4999
5006
|
}
|
|
5000
5007
|
|
|
5008
|
+
.sw-site-header-compact-actions {
|
|
5009
|
+
display: contents;
|
|
5010
|
+
}
|
|
5011
|
+
|
|
5001
5012
|
.sw-site-header-list {
|
|
5002
5013
|
list-style: none;
|
|
5003
5014
|
margin: 0;
|
|
@@ -5054,6 +5065,10 @@ body {
|
|
|
5054
5065
|
list-style: none;
|
|
5055
5066
|
}
|
|
5056
5067
|
|
|
5068
|
+
.sw-site-header-compact:has(> .sw-site-header-menu[open]) > .sw-site-header-compact-actions {
|
|
5069
|
+
display: none;
|
|
5070
|
+
}
|
|
5071
|
+
|
|
5057
5072
|
.sw-site-header-menu > summary::-webkit-details-marker {
|
|
5058
5073
|
display: none;
|
|
5059
5074
|
}
|
|
@@ -5138,6 +5153,52 @@ body {
|
|
|
5138
5153
|
padding-top: var(--sw-space-5);
|
|
5139
5154
|
}
|
|
5140
5155
|
|
|
5156
|
+
.sw-site-header-skip-link {
|
|
5157
|
+
align-items: center;
|
|
5158
|
+
background: var(--sw-color-surface);
|
|
5159
|
+
border-radius: var(--sw-radius-pill);
|
|
5160
|
+
box-sizing: border-box;
|
|
5161
|
+
display: inline-flex;
|
|
5162
|
+
font-family: var(--sw-font-sans);
|
|
5163
|
+
font-size: 15px;
|
|
5164
|
+
font-weight: 600;
|
|
5165
|
+
inset-block-start: 50%;
|
|
5166
|
+
inset-inline-start: calc(max(0px, (100% - calc(var(--sw-container-max) + 2 * var(--sw-space-5))) / 2) + var(--sw-space-4));
|
|
5167
|
+
letter-spacing: -0.2px;
|
|
5168
|
+
line-height: 20px;
|
|
5169
|
+
padding-inline: var(--sw-space-4);
|
|
5170
|
+
position: absolute;
|
|
5171
|
+
transform: translateY(-50%);
|
|
5172
|
+
white-space: nowrap;
|
|
5173
|
+
}
|
|
5174
|
+
|
|
5175
|
+
[data-theme] .sw-site-header-skip-link {
|
|
5176
|
+
color: var(--sw-color-text);
|
|
5177
|
+
text-decoration: none;
|
|
5178
|
+
}
|
|
5179
|
+
|
|
5180
|
+
[data-theme] .sw-site-header-skip-link:focus {
|
|
5181
|
+
min-height: var(--sw-control-md-min-height);
|
|
5182
|
+
outline: var(--sw-focus-ring-width) solid var(--sw-color-accent);
|
|
5183
|
+
outline-offset: calc(-1 * var(--sw-focus-ring-width));
|
|
5184
|
+
}
|
|
5185
|
+
|
|
5186
|
+
.sw-site-header-skip-link:not(:focus) { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
|
|
5187
|
+
|
|
5188
|
+
@media (min-width: 48rem) {
|
|
5189
|
+
.sw-site-header-skip-link { inset-inline-start: calc(max(0px, (100% - calc(var(--sw-container-max) + 2 * var(--sw-space-5))) / 2) + var(--sw-space-5)); }
|
|
5190
|
+
}
|
|
5191
|
+
|
|
5192
|
+
html:has(.sw-site-header-sticky) {
|
|
5193
|
+
scroll-padding-top: calc(calc(var(--sw-control-md-min-height) + var(--sw-space-3)) + 1px + var(--sw-space-3));
|
|
5194
|
+
}
|
|
5195
|
+
|
|
5196
|
+
@media (min-width: 64rem) {
|
|
5197
|
+
html:has(.sw-site-header-sticky) {
|
|
5198
|
+
scroll-padding-top: calc(calc(var(--sw-control-md-min-height) + var(--sw-space-5) + var(--sw-space-1)) + 1px + var(--sw-space-3));
|
|
5199
|
+
}
|
|
5200
|
+
}
|
|
5201
|
+
|
|
5141
5202
|
/*
|
|
5142
5203
|
* Forced colours drop box shadows, so the current destination's bar is a
|
|
5143
5204
|
* thick underline there.
|