@skipleague/design 0.7.0 → 0.8.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/dist/IconRail.d.ts +33 -0
- package/dist/IconRail.js +44 -0
- package/dist/ProfileMenu.js +4 -0
- package/dist/ResponsiveShell.d.ts +45 -0
- package/dist/ResponsiveShell.js +81 -0
- package/dist/TopBar.js +3 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +2 -0
- package/package.json +1 -1
- package/src/IconRail.tsx +96 -0
- package/src/ProfileMenu.tsx +4 -0
- package/src/ResponsiveShell.tsx +122 -0
- package/src/TopBar.tsx +3 -0
- package/src/index.ts +4 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { CSSProperties, ReactNode } from "react";
|
|
2
|
+
/** A single destination in the tablet {@link IconRail}. */
|
|
3
|
+
export interface IconRailItem {
|
|
4
|
+
/** Stable key. */
|
|
5
|
+
key: string;
|
|
6
|
+
label: string;
|
|
7
|
+
/** ~21px line icon (stroke 2, currentColor) — inherits the item's color. */
|
|
8
|
+
icon: ReactNode;
|
|
9
|
+
/** Current route — highlighted brand. Derive from your router. */
|
|
10
|
+
active?: boolean;
|
|
11
|
+
/** Renders an `<a href>`; omit for a `<button>` (pure onClick). */
|
|
12
|
+
href?: string;
|
|
13
|
+
onClick?: () => void;
|
|
14
|
+
}
|
|
15
|
+
export interface IconRailProps {
|
|
16
|
+
items: IconRailItem[];
|
|
17
|
+
/** Rail width in px (default 78 — the shell's tablet nav column). */
|
|
18
|
+
width?: number;
|
|
19
|
+
/** Route item links via your own router (e.g. React Router's `<Link>`). */
|
|
20
|
+
renderLink?: (args: {
|
|
21
|
+
href: string;
|
|
22
|
+
style: CSSProperties;
|
|
23
|
+
"aria-current"?: "page";
|
|
24
|
+
children: ReactNode;
|
|
25
|
+
}) => ReactNode;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The tablet-width vertical navigation rail (design_handoff_responsive_shell) —
|
|
29
|
+
* icon-over-label stacks in a 78px column. Sits in the `rail` slot of
|
|
30
|
+
* {@link ResponsiveShell} between the phone bottom bar and the desktop
|
|
31
|
+
* {@link SidebarNav}. Nav only; identity/account live in the top bar.
|
|
32
|
+
*/
|
|
33
|
+
export declare function IconRail({ items, width, renderLink }: IconRailProps): import("react").JSX.Element;
|
package/dist/IconRail.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* The tablet-width vertical navigation rail (design_handoff_responsive_shell) —
|
|
4
|
+
* icon-over-label stacks in a 78px column. Sits in the `rail` slot of
|
|
5
|
+
* {@link ResponsiveShell} between the phone bottom bar and the desktop
|
|
6
|
+
* {@link SidebarNav}. Nav only; identity/account live in the top bar.
|
|
7
|
+
*/
|
|
8
|
+
export function IconRail({ items, width = 78, renderLink }) {
|
|
9
|
+
return (_jsx("nav", { style: {
|
|
10
|
+
width,
|
|
11
|
+
height: "100%",
|
|
12
|
+
display: "flex",
|
|
13
|
+
flexDirection: "column",
|
|
14
|
+
alignItems: "stretch",
|
|
15
|
+
gap: 4,
|
|
16
|
+
padding: "14px 10px",
|
|
17
|
+
background: "#ffffff",
|
|
18
|
+
fontFamily: "var(--skl-font-sans)",
|
|
19
|
+
}, children: items.map((item) => (_jsx(Item, { item: item, renderLink: renderLink }, item.key))) }));
|
|
20
|
+
}
|
|
21
|
+
function Item({ item, renderLink, }) {
|
|
22
|
+
const { label, icon, active, href, onClick } = item;
|
|
23
|
+
const style = {
|
|
24
|
+
display: "flex",
|
|
25
|
+
flexDirection: "column",
|
|
26
|
+
justifyContent: "center",
|
|
27
|
+
alignItems: "center",
|
|
28
|
+
gap: 5,
|
|
29
|
+
padding: "11px 6px",
|
|
30
|
+
borderRadius: 12,
|
|
31
|
+
textDecoration: "none",
|
|
32
|
+
cursor: "pointer",
|
|
33
|
+
color: active ? "var(--skl-color-brand)" : "#94a3b8",
|
|
34
|
+
background: active ? "var(--skl-color-current-bg)" : "transparent",
|
|
35
|
+
};
|
|
36
|
+
const inner = (_jsxs(_Fragment, { children: [icon, _jsx("span", { style: { fontSize: 9.5, fontWeight: 600, lineHeight: 1 }, children: label })] }));
|
|
37
|
+
if (href) {
|
|
38
|
+
if (renderLink) {
|
|
39
|
+
return renderLink({ href, style, "aria-current": active ? "page" : undefined, children: inner });
|
|
40
|
+
}
|
|
41
|
+
return (_jsx("a", { href: href, style: style, "aria-current": active ? "page" : undefined, onClick: onClick, children: inner }));
|
|
42
|
+
}
|
|
43
|
+
return (_jsx("button", { type: "button", onClick: onClick, "aria-current": active ? "page" : undefined, style: { ...style, border: "none", width: "100%" }, children: inner }));
|
|
44
|
+
}
|
package/dist/ProfileMenu.js
CHANGED
|
@@ -57,6 +57,10 @@ export function ProfileMenu({ user, currentSlug, apps = SKIPLEAGUE_APPS, enabled
|
|
|
57
57
|
display: "inline-flex",
|
|
58
58
|
alignItems: "center",
|
|
59
59
|
justifyContent: "center",
|
|
60
|
+
// Explicit padding:0 — a host app's global `button { padding }` would
|
|
61
|
+
// otherwise collapse this fixed 38×38 box's content area (border-box)
|
|
62
|
+
// and shrink the icon to a dot.
|
|
63
|
+
padding: 0,
|
|
60
64
|
width: 38,
|
|
61
65
|
height: 38,
|
|
62
66
|
borderRadius: "var(--skl-radius-control)",
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
/**
|
|
3
|
+
* Class for an in-detail control that should only show while the detail panel is
|
|
4
|
+
* a phone/tablet overlay (e.g. its back button) — hidden once the panel is
|
|
5
|
+
* docked on desktop. Put it on the app's detail back button:
|
|
6
|
+
* `<button className={RESPONSIVE_SHELL_DETAIL_BACK_CLASS} onClick={onClose}>`.
|
|
7
|
+
*/
|
|
8
|
+
export declare const RESPONSIVE_SHELL_DETAIL_BACK_CLASS = "skl-shell-detail-back";
|
|
9
|
+
export interface ResponsiveShellProps {
|
|
10
|
+
/** Persistent top bar — spans the full width at every breakpoint (e.g. TopBar/DesktopActionBar). */
|
|
11
|
+
topBar: ReactNode;
|
|
12
|
+
/** The center content column (scrolls). */
|
|
13
|
+
main: ReactNode;
|
|
14
|
+
/** Phone nav (`< 720px`) — e.g. the app's BottomNav. Hidden at wider widths. */
|
|
15
|
+
bottomNav?: ReactNode;
|
|
16
|
+
/** Tablet nav (`720–1079px`) — e.g. {@link IconRail}. Hidden otherwise. */
|
|
17
|
+
rail?: ReactNode;
|
|
18
|
+
/** Desktop nav (`≥ 1080px`) — e.g. SidebarNav (pass width 236 to fill the column). */
|
|
19
|
+
sidebar?: ReactNode;
|
|
20
|
+
/** Master-detail panel: docked right column on desktop; full-screen overlay on phone/tablet. */
|
|
21
|
+
detail?: ReactNode;
|
|
22
|
+
/** Phone/tablet overlay visibility. Ignored on desktop, where detail is always docked. */
|
|
23
|
+
detailOpen?: boolean;
|
|
24
|
+
/** Optional id for scoping/testing. */
|
|
25
|
+
id?: string;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The SkipLeague adaptive app shell (design_handoff_responsive_shell).
|
|
29
|
+
*
|
|
30
|
+
* ONE layout that reflows across three breakpoints — no separate mobile/desktop
|
|
31
|
+
* product. The top bar is constant; the nav and detail regions rearrange:
|
|
32
|
+
*
|
|
33
|
+
* Phone (<720): top bar / content / bottom tab bar; detail = full overlay
|
|
34
|
+
* Tablet (720–1079): top bar / [icon rail | content]; detail = full overlay
|
|
35
|
+
* Desktop (≥1080): top bar / [sidebar | content | docked detail]
|
|
36
|
+
*
|
|
37
|
+
* The detail is the SAME element everywhere (list-detail / master-detail): a
|
|
38
|
+
* drill-in overlay on phone/tablet, a permanently docked right column on desktop.
|
|
39
|
+
* Pass the app's own nav components into `bottomNav` / `rail` / `sidebar` — the
|
|
40
|
+
* shell shows the right one per breakpoint via CSS (no JS width state needed).
|
|
41
|
+
*
|
|
42
|
+
* Give the shell a fixed-height parent (e.g. `height: 100dvh`); it fills it and
|
|
43
|
+
* scrolls internally.
|
|
44
|
+
*/
|
|
45
|
+
export declare function ResponsiveShell({ topBar, main, bottomNav, rail, sidebar, detail, detailOpen, id, }: ResponsiveShellProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* Class for an in-detail control that should only show while the detail panel is
|
|
4
|
+
* a phone/tablet overlay (e.g. its back button) — hidden once the panel is
|
|
5
|
+
* docked on desktop. Put it on the app's detail back button:
|
|
6
|
+
* `<button className={RESPONSIVE_SHELL_DETAIL_BACK_CLASS} onClick={onClose}>`.
|
|
7
|
+
*/
|
|
8
|
+
export const RESPONSIVE_SHELL_DETAIL_BACK_CLASS = "skl-shell-detail-back";
|
|
9
|
+
// Breakpoints: phone < 720 ≤ tablet < 1080 ≤ desktop. One list-detail layout that
|
|
10
|
+
// reflows — the desktop right panel IS the mobile drill-in, given a permanent column.
|
|
11
|
+
const CSS = `
|
|
12
|
+
.skl-shell {
|
|
13
|
+
height: 100%;
|
|
14
|
+
position: relative;
|
|
15
|
+
overflow: hidden;
|
|
16
|
+
display: flex;
|
|
17
|
+
flex-direction: column;
|
|
18
|
+
background: var(--skl-color-app-bg, #f8fafc);
|
|
19
|
+
font-family: var(--skl-font-sans);
|
|
20
|
+
}
|
|
21
|
+
.skl-shell__top { flex: 0 0 auto; }
|
|
22
|
+
.skl-shell__main { flex: 1 1 auto; min-height: 0; overflow-y: auto; }
|
|
23
|
+
.skl-shell__bottom { flex: 0 0 auto; }
|
|
24
|
+
.skl-shell__rail, .skl-shell__sidebar { display: none; }
|
|
25
|
+
.skl-shell__detail {
|
|
26
|
+
position: absolute; inset: 0; z-index: 40; background: #fff;
|
|
27
|
+
display: none; flex-direction: column;
|
|
28
|
+
}
|
|
29
|
+
.skl-shell__detail.is-open { display: flex; animation: sklShellFade .2s ease-out; }
|
|
30
|
+
.${RESPONSIVE_SHELL_DETAIL_BACK_CLASS} { display: inline-flex; }
|
|
31
|
+
@keyframes sklShellFade { from { opacity: 0; } to { opacity: 1; } }
|
|
32
|
+
|
|
33
|
+
@media (min-width: 720px) {
|
|
34
|
+
.skl-shell {
|
|
35
|
+
display: grid;
|
|
36
|
+
grid-template-columns: 78px 1fr;
|
|
37
|
+
grid-template-rows: auto minmax(0, 1fr);
|
|
38
|
+
grid-template-areas: "top top" "nav main";
|
|
39
|
+
}
|
|
40
|
+
.skl-shell__top { grid-area: top; }
|
|
41
|
+
.skl-shell__main { grid-area: main; height: 100%; }
|
|
42
|
+
.skl-shell__rail { grid-area: nav; display: flex; border-right: 1px solid var(--skl-color-border); }
|
|
43
|
+
.skl-shell__sidebar { grid-area: nav; }
|
|
44
|
+
.skl-shell__bottom { display: none; }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
@media (min-width: 1080px) {
|
|
48
|
+
.skl-shell {
|
|
49
|
+
grid-template-columns: 236px 1fr 340px;
|
|
50
|
+
grid-template-areas: "top top top" "nav main detail";
|
|
51
|
+
}
|
|
52
|
+
.skl-shell__rail { display: none; }
|
|
53
|
+
.skl-shell__sidebar { grid-area: nav; display: block; border-right: 1px solid var(--skl-color-border); }
|
|
54
|
+
.skl-shell__detail {
|
|
55
|
+
position: relative; inset: auto; z-index: auto; grid-area: detail;
|
|
56
|
+
display: flex; border-left: 1px solid var(--skl-color-border); overflow-y: auto;
|
|
57
|
+
}
|
|
58
|
+
.${RESPONSIVE_SHELL_DETAIL_BACK_CLASS} { display: none; }
|
|
59
|
+
}
|
|
60
|
+
`;
|
|
61
|
+
/**
|
|
62
|
+
* The SkipLeague adaptive app shell (design_handoff_responsive_shell).
|
|
63
|
+
*
|
|
64
|
+
* ONE layout that reflows across three breakpoints — no separate mobile/desktop
|
|
65
|
+
* product. The top bar is constant; the nav and detail regions rearrange:
|
|
66
|
+
*
|
|
67
|
+
* Phone (<720): top bar / content / bottom tab bar; detail = full overlay
|
|
68
|
+
* Tablet (720–1079): top bar / [icon rail | content]; detail = full overlay
|
|
69
|
+
* Desktop (≥1080): top bar / [sidebar | content | docked detail]
|
|
70
|
+
*
|
|
71
|
+
* The detail is the SAME element everywhere (list-detail / master-detail): a
|
|
72
|
+
* drill-in overlay on phone/tablet, a permanently docked right column on desktop.
|
|
73
|
+
* Pass the app's own nav components into `bottomNav` / `rail` / `sidebar` — the
|
|
74
|
+
* shell shows the right one per breakpoint via CSS (no JS width state needed).
|
|
75
|
+
*
|
|
76
|
+
* Give the shell a fixed-height parent (e.g. `height: 100dvh`); it fills it and
|
|
77
|
+
* scrolls internally.
|
|
78
|
+
*/
|
|
79
|
+
export function ResponsiveShell({ topBar, main, bottomNav, rail, sidebar, detail, detailOpen = false, id, }) {
|
|
80
|
+
return (_jsxs("div", { className: "skl-shell", id: id, children: [_jsx("style", { children: CSS }), _jsx("div", { className: "skl-shell__top", children: topBar }), sidebar && _jsx("div", { className: "skl-shell__sidebar", children: sidebar }), rail && _jsx("div", { className: "skl-shell__rail", children: rail }), _jsx("div", { className: "skl-shell__main", children: main }), detail && _jsx("div", { className: `skl-shell__detail${detailOpen ? " is-open" : ""}`, children: detail }), bottomNav && _jsx("div", { className: "skl-shell__bottom", children: bottomNav })] }));
|
|
81
|
+
}
|
package/dist/TopBar.js
CHANGED
|
@@ -82,6 +82,9 @@ export function TopBarIconButton({ tone = "dark", compact = false, style, childr
|
|
|
82
82
|
justifyContent: "center",
|
|
83
83
|
cursor: "pointer",
|
|
84
84
|
flex: "0 0 auto",
|
|
85
|
+
// Explicit padding:0 so a host app's global `button { padding }` can't
|
|
86
|
+
// collapse this fixed-size box (border-box) and shrink the icon.
|
|
87
|
+
padding: 0,
|
|
85
88
|
background: palette.background,
|
|
86
89
|
border: `1px solid ${palette.border}`,
|
|
87
90
|
color: palette.color,
|
package/dist/index.d.ts
CHANGED
|
@@ -7,6 +7,10 @@ export { TopBar, TopBarIconButton, DesktopActionBar } from "./TopBar.js";
|
|
|
7
7
|
export type { TopBarTone } from "./TopBar.js";
|
|
8
8
|
export { SidebarNav } from "./SidebarNav.js";
|
|
9
9
|
export type { SidebarNavProps, SidebarNavSection, SidebarNavItem, SidebarNavLinkArgs } from "./SidebarNav.js";
|
|
10
|
+
export { IconRail } from "./IconRail.js";
|
|
11
|
+
export type { IconRailProps, IconRailItem } from "./IconRail.js";
|
|
12
|
+
export { ResponsiveShell, RESPONSIVE_SHELL_DETAIL_BACK_CLASS } from "./ResponsiveShell.js";
|
|
13
|
+
export type { ResponsiveShellProps } from "./ResponsiveShell.js";
|
|
10
14
|
export { BottomNav } from "./BottomNav.js";
|
|
11
15
|
export type { BottomNavProps, BottomNavTab, BottomNavAction } from "./BottomNav.js";
|
|
12
16
|
export { ShareMenu } from "./ShareMenu.js";
|
package/dist/index.js
CHANGED
|
@@ -3,6 +3,8 @@ export { AppBadge } from "./AppBadge.js";
|
|
|
3
3
|
export { AppLogo, APP_GLYPHS, appGlyphForSlug } from "./AppLogo.js";
|
|
4
4
|
export { TopBar, TopBarIconButton, DesktopActionBar } from "./TopBar.js";
|
|
5
5
|
export { SidebarNav } from "./SidebarNav.js";
|
|
6
|
+
export { IconRail } from "./IconRail.js";
|
|
7
|
+
export { ResponsiveShell, RESPONSIVE_SHELL_DETAIL_BACK_CLASS } from "./ResponsiveShell.js";
|
|
6
8
|
export { BottomNav } from "./BottomNav.js";
|
|
7
9
|
export { ShareMenu } from "./ShareMenu.js";
|
|
8
10
|
export { Button } from "./Button.js";
|
package/package.json
CHANGED
package/src/IconRail.tsx
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { CSSProperties, ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
/** A single destination in the tablet {@link IconRail}. */
|
|
4
|
+
export interface IconRailItem {
|
|
5
|
+
/** Stable key. */
|
|
6
|
+
key: string;
|
|
7
|
+
label: string;
|
|
8
|
+
/** ~21px line icon (stroke 2, currentColor) — inherits the item's color. */
|
|
9
|
+
icon: ReactNode;
|
|
10
|
+
/** Current route — highlighted brand. Derive from your router. */
|
|
11
|
+
active?: boolean;
|
|
12
|
+
/** Renders an `<a href>`; omit for a `<button>` (pure onClick). */
|
|
13
|
+
href?: string;
|
|
14
|
+
onClick?: () => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface IconRailProps {
|
|
18
|
+
items: IconRailItem[];
|
|
19
|
+
/** Rail width in px (default 78 — the shell's tablet nav column). */
|
|
20
|
+
width?: number;
|
|
21
|
+
/** Route item links via your own router (e.g. React Router's `<Link>`). */
|
|
22
|
+
renderLink?: (args: { href: string; style: CSSProperties; "aria-current"?: "page"; children: ReactNode }) => ReactNode;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The tablet-width vertical navigation rail (design_handoff_responsive_shell) —
|
|
27
|
+
* icon-over-label stacks in a 78px column. Sits in the `rail` slot of
|
|
28
|
+
* {@link ResponsiveShell} between the phone bottom bar and the desktop
|
|
29
|
+
* {@link SidebarNav}. Nav only; identity/account live in the top bar.
|
|
30
|
+
*/
|
|
31
|
+
export function IconRail({ items, width = 78, renderLink }: IconRailProps) {
|
|
32
|
+
return (
|
|
33
|
+
<nav
|
|
34
|
+
style={{
|
|
35
|
+
width,
|
|
36
|
+
height: "100%",
|
|
37
|
+
display: "flex",
|
|
38
|
+
flexDirection: "column",
|
|
39
|
+
alignItems: "stretch",
|
|
40
|
+
gap: 4,
|
|
41
|
+
padding: "14px 10px",
|
|
42
|
+
background: "#ffffff",
|
|
43
|
+
fontFamily: "var(--skl-font-sans)",
|
|
44
|
+
}}
|
|
45
|
+
>
|
|
46
|
+
{items.map((item) => (
|
|
47
|
+
<Item key={item.key} item={item} renderLink={renderLink} />
|
|
48
|
+
))}
|
|
49
|
+
</nav>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function Item({
|
|
54
|
+
item,
|
|
55
|
+
renderLink,
|
|
56
|
+
}: {
|
|
57
|
+
item: IconRailItem;
|
|
58
|
+
renderLink?: IconRailProps["renderLink"];
|
|
59
|
+
}) {
|
|
60
|
+
const { label, icon, active, href, onClick } = item;
|
|
61
|
+
const style: CSSProperties = {
|
|
62
|
+
display: "flex",
|
|
63
|
+
flexDirection: "column",
|
|
64
|
+
justifyContent: "center",
|
|
65
|
+
alignItems: "center",
|
|
66
|
+
gap: 5,
|
|
67
|
+
padding: "11px 6px",
|
|
68
|
+
borderRadius: 12,
|
|
69
|
+
textDecoration: "none",
|
|
70
|
+
cursor: "pointer",
|
|
71
|
+
color: active ? "var(--skl-color-brand)" : "#94a3b8",
|
|
72
|
+
background: active ? "var(--skl-color-current-bg)" : "transparent",
|
|
73
|
+
};
|
|
74
|
+
const inner = (
|
|
75
|
+
<>
|
|
76
|
+
{icon}
|
|
77
|
+
<span style={{ fontSize: 9.5, fontWeight: 600, lineHeight: 1 }}>{label}</span>
|
|
78
|
+
</>
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
if (href) {
|
|
82
|
+
if (renderLink) {
|
|
83
|
+
return renderLink({ href, style, "aria-current": active ? "page" : undefined, children: inner });
|
|
84
|
+
}
|
|
85
|
+
return (
|
|
86
|
+
<a href={href} style={style} aria-current={active ? "page" : undefined} onClick={onClick}>
|
|
87
|
+
{inner}
|
|
88
|
+
</a>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
return (
|
|
92
|
+
<button type="button" onClick={onClick} aria-current={active ? "page" : undefined} style={{ ...style, border: "none", width: "100%" }}>
|
|
93
|
+
{inner}
|
|
94
|
+
</button>
|
|
95
|
+
);
|
|
96
|
+
}
|
package/src/ProfileMenu.tsx
CHANGED
|
@@ -161,6 +161,10 @@ export function ProfileMenu({
|
|
|
161
161
|
display: "inline-flex",
|
|
162
162
|
alignItems: "center",
|
|
163
163
|
justifyContent: "center",
|
|
164
|
+
// Explicit padding:0 — a host app's global `button { padding }` would
|
|
165
|
+
// otherwise collapse this fixed 38×38 box's content area (border-box)
|
|
166
|
+
// and shrink the icon to a dot.
|
|
167
|
+
padding: 0,
|
|
164
168
|
width: 38,
|
|
165
169
|
height: 38,
|
|
166
170
|
borderRadius: "var(--skl-radius-control)",
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Class for an in-detail control that should only show while the detail panel is
|
|
5
|
+
* a phone/tablet overlay (e.g. its back button) — hidden once the panel is
|
|
6
|
+
* docked on desktop. Put it on the app's detail back button:
|
|
7
|
+
* `<button className={RESPONSIVE_SHELL_DETAIL_BACK_CLASS} onClick={onClose}>`.
|
|
8
|
+
*/
|
|
9
|
+
export const RESPONSIVE_SHELL_DETAIL_BACK_CLASS = "skl-shell-detail-back";
|
|
10
|
+
|
|
11
|
+
export interface ResponsiveShellProps {
|
|
12
|
+
/** Persistent top bar — spans the full width at every breakpoint (e.g. TopBar/DesktopActionBar). */
|
|
13
|
+
topBar: ReactNode;
|
|
14
|
+
/** The center content column (scrolls). */
|
|
15
|
+
main: ReactNode;
|
|
16
|
+
/** Phone nav (`< 720px`) — e.g. the app's BottomNav. Hidden at wider widths. */
|
|
17
|
+
bottomNav?: ReactNode;
|
|
18
|
+
/** Tablet nav (`720–1079px`) — e.g. {@link IconRail}. Hidden otherwise. */
|
|
19
|
+
rail?: ReactNode;
|
|
20
|
+
/** Desktop nav (`≥ 1080px`) — e.g. SidebarNav (pass width 236 to fill the column). */
|
|
21
|
+
sidebar?: ReactNode;
|
|
22
|
+
/** Master-detail panel: docked right column on desktop; full-screen overlay on phone/tablet. */
|
|
23
|
+
detail?: ReactNode;
|
|
24
|
+
/** Phone/tablet overlay visibility. Ignored on desktop, where detail is always docked. */
|
|
25
|
+
detailOpen?: boolean;
|
|
26
|
+
/** Optional id for scoping/testing. */
|
|
27
|
+
id?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Breakpoints: phone < 720 ≤ tablet < 1080 ≤ desktop. One list-detail layout that
|
|
31
|
+
// reflows — the desktop right panel IS the mobile drill-in, given a permanent column.
|
|
32
|
+
const CSS = `
|
|
33
|
+
.skl-shell {
|
|
34
|
+
height: 100%;
|
|
35
|
+
position: relative;
|
|
36
|
+
overflow: hidden;
|
|
37
|
+
display: flex;
|
|
38
|
+
flex-direction: column;
|
|
39
|
+
background: var(--skl-color-app-bg, #f8fafc);
|
|
40
|
+
font-family: var(--skl-font-sans);
|
|
41
|
+
}
|
|
42
|
+
.skl-shell__top { flex: 0 0 auto; }
|
|
43
|
+
.skl-shell__main { flex: 1 1 auto; min-height: 0; overflow-y: auto; }
|
|
44
|
+
.skl-shell__bottom { flex: 0 0 auto; }
|
|
45
|
+
.skl-shell__rail, .skl-shell__sidebar { display: none; }
|
|
46
|
+
.skl-shell__detail {
|
|
47
|
+
position: absolute; inset: 0; z-index: 40; background: #fff;
|
|
48
|
+
display: none; flex-direction: column;
|
|
49
|
+
}
|
|
50
|
+
.skl-shell__detail.is-open { display: flex; animation: sklShellFade .2s ease-out; }
|
|
51
|
+
.${RESPONSIVE_SHELL_DETAIL_BACK_CLASS} { display: inline-flex; }
|
|
52
|
+
@keyframes sklShellFade { from { opacity: 0; } to { opacity: 1; } }
|
|
53
|
+
|
|
54
|
+
@media (min-width: 720px) {
|
|
55
|
+
.skl-shell {
|
|
56
|
+
display: grid;
|
|
57
|
+
grid-template-columns: 78px 1fr;
|
|
58
|
+
grid-template-rows: auto minmax(0, 1fr);
|
|
59
|
+
grid-template-areas: "top top" "nav main";
|
|
60
|
+
}
|
|
61
|
+
.skl-shell__top { grid-area: top; }
|
|
62
|
+
.skl-shell__main { grid-area: main; height: 100%; }
|
|
63
|
+
.skl-shell__rail { grid-area: nav; display: flex; border-right: 1px solid var(--skl-color-border); }
|
|
64
|
+
.skl-shell__sidebar { grid-area: nav; }
|
|
65
|
+
.skl-shell__bottom { display: none; }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
@media (min-width: 1080px) {
|
|
69
|
+
.skl-shell {
|
|
70
|
+
grid-template-columns: 236px 1fr 340px;
|
|
71
|
+
grid-template-areas: "top top top" "nav main detail";
|
|
72
|
+
}
|
|
73
|
+
.skl-shell__rail { display: none; }
|
|
74
|
+
.skl-shell__sidebar { grid-area: nav; display: block; border-right: 1px solid var(--skl-color-border); }
|
|
75
|
+
.skl-shell__detail {
|
|
76
|
+
position: relative; inset: auto; z-index: auto; grid-area: detail;
|
|
77
|
+
display: flex; border-left: 1px solid var(--skl-color-border); overflow-y: auto;
|
|
78
|
+
}
|
|
79
|
+
.${RESPONSIVE_SHELL_DETAIL_BACK_CLASS} { display: none; }
|
|
80
|
+
}
|
|
81
|
+
`;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The SkipLeague adaptive app shell (design_handoff_responsive_shell).
|
|
85
|
+
*
|
|
86
|
+
* ONE layout that reflows across three breakpoints — no separate mobile/desktop
|
|
87
|
+
* product. The top bar is constant; the nav and detail regions rearrange:
|
|
88
|
+
*
|
|
89
|
+
* Phone (<720): top bar / content / bottom tab bar; detail = full overlay
|
|
90
|
+
* Tablet (720–1079): top bar / [icon rail | content]; detail = full overlay
|
|
91
|
+
* Desktop (≥1080): top bar / [sidebar | content | docked detail]
|
|
92
|
+
*
|
|
93
|
+
* The detail is the SAME element everywhere (list-detail / master-detail): a
|
|
94
|
+
* drill-in overlay on phone/tablet, a permanently docked right column on desktop.
|
|
95
|
+
* Pass the app's own nav components into `bottomNav` / `rail` / `sidebar` — the
|
|
96
|
+
* shell shows the right one per breakpoint via CSS (no JS width state needed).
|
|
97
|
+
*
|
|
98
|
+
* Give the shell a fixed-height parent (e.g. `height: 100dvh`); it fills it and
|
|
99
|
+
* scrolls internally.
|
|
100
|
+
*/
|
|
101
|
+
export function ResponsiveShell({
|
|
102
|
+
topBar,
|
|
103
|
+
main,
|
|
104
|
+
bottomNav,
|
|
105
|
+
rail,
|
|
106
|
+
sidebar,
|
|
107
|
+
detail,
|
|
108
|
+
detailOpen = false,
|
|
109
|
+
id,
|
|
110
|
+
}: ResponsiveShellProps) {
|
|
111
|
+
return (
|
|
112
|
+
<div className="skl-shell" id={id}>
|
|
113
|
+
<style>{CSS}</style>
|
|
114
|
+
<div className="skl-shell__top">{topBar}</div>
|
|
115
|
+
{sidebar && <div className="skl-shell__sidebar">{sidebar}</div>}
|
|
116
|
+
{rail && <div className="skl-shell__rail">{rail}</div>}
|
|
117
|
+
<div className="skl-shell__main">{main}</div>
|
|
118
|
+
{detail && <div className={`skl-shell__detail${detailOpen ? " is-open" : ""}`}>{detail}</div>}
|
|
119
|
+
{bottomNav && <div className="skl-shell__bottom">{bottomNav}</div>}
|
|
120
|
+
</div>
|
|
121
|
+
);
|
|
122
|
+
}
|
package/src/TopBar.tsx
CHANGED
|
@@ -168,6 +168,9 @@ export function TopBarIconButton({
|
|
|
168
168
|
justifyContent: "center",
|
|
169
169
|
cursor: "pointer",
|
|
170
170
|
flex: "0 0 auto",
|
|
171
|
+
// Explicit padding:0 so a host app's global `button { padding }` can't
|
|
172
|
+
// collapse this fixed-size box (border-box) and shrink the icon.
|
|
173
|
+
padding: 0,
|
|
171
174
|
background: palette.background,
|
|
172
175
|
border: `1px solid ${palette.border}`,
|
|
173
176
|
color: palette.color,
|
package/src/index.ts
CHANGED
|
@@ -7,6 +7,10 @@ export { TopBar, TopBarIconButton, DesktopActionBar } from "./TopBar.js";
|
|
|
7
7
|
export type { TopBarTone } from "./TopBar.js";
|
|
8
8
|
export { SidebarNav } from "./SidebarNav.js";
|
|
9
9
|
export type { SidebarNavProps, SidebarNavSection, SidebarNavItem, SidebarNavLinkArgs } from "./SidebarNav.js";
|
|
10
|
+
export { IconRail } from "./IconRail.js";
|
|
11
|
+
export type { IconRailProps, IconRailItem } from "./IconRail.js";
|
|
12
|
+
export { ResponsiveShell, RESPONSIVE_SHELL_DETAIL_BACK_CLASS } from "./ResponsiveShell.js";
|
|
13
|
+
export type { ResponsiveShellProps } from "./ResponsiveShell.js";
|
|
10
14
|
export { BottomNav } from "./BottomNav.js";
|
|
11
15
|
export type { BottomNavProps, BottomNavTab, BottomNavAction } from "./BottomNav.js";
|
|
12
16
|
export { ShareMenu } from "./ShareMenu.js";
|