@skipleague/design 0.5.0 → 0.7.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/AppLogo.d.ts +8 -0
- package/dist/AppLogo.js +20 -0
- package/dist/ProfileMenu.js +4 -1
- package/dist/SidebarNav.d.ts +50 -0
- package/dist/SidebarNav.js +62 -0
- package/dist/TopBar.d.ts +44 -13
- package/dist/TopBar.js +62 -23
- package/dist/index.d.ts +5 -2
- package/dist/index.js +3 -2
- package/package.json +1 -1
- package/src/AppLogo.tsx +22 -0
- package/src/ProfileMenu.tsx +4 -1
- package/src/SidebarNav.tsx +152 -0
- package/src/TopBar.tsx +108 -25
- package/src/index.ts +5 -2
package/dist/AppLogo.d.ts
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
/** A SkipLeague app's logo glyph (monoline, drawn on the brand tile). */
|
|
2
2
|
export type AppGlyph = "lists" | "racquetball" | "trips" | "gifts" | "reading" | "today" | "guide" | "flow";
|
|
3
|
+
/** Every app that has a glyph, for slug→glyph resolution and fallbacks. */
|
|
4
|
+
export declare const APP_GLYPHS: readonly AppGlyph[];
|
|
5
|
+
/**
|
|
6
|
+
* Resolve a platform app slug (e.g. `"skipracquetball"`) to its {@link AppGlyph}
|
|
7
|
+
* by dropping the `skip` prefix, or `null` when no glyph exists for it (so a
|
|
8
|
+
* caller can fall back to a letter {@link AppBadge}). Case-insensitive.
|
|
9
|
+
*/
|
|
10
|
+
export declare function appGlyphForSlug(slug: string): AppGlyph | null;
|
|
3
11
|
/**
|
|
4
12
|
* The per-app logo mark — a brand-green rounded tile holding a white monoline
|
|
5
13
|
* glyph. Replaces the single-letter {@link AppBadge}; gives each SkipLeague app a
|
package/dist/AppLogo.js
CHANGED
|
@@ -1,4 +1,24 @@
|
|
|
1
1
|
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/** Every app that has a glyph, for slug→glyph resolution and fallbacks. */
|
|
3
|
+
export const APP_GLYPHS = [
|
|
4
|
+
"lists",
|
|
5
|
+
"racquetball",
|
|
6
|
+
"trips",
|
|
7
|
+
"gifts",
|
|
8
|
+
"reading",
|
|
9
|
+
"today",
|
|
10
|
+
"guide",
|
|
11
|
+
"flow",
|
|
12
|
+
];
|
|
13
|
+
/**
|
|
14
|
+
* Resolve a platform app slug (e.g. `"skipracquetball"`) to its {@link AppGlyph}
|
|
15
|
+
* by dropping the `skip` prefix, or `null` when no glyph exists for it (so a
|
|
16
|
+
* caller can fall back to a letter {@link AppBadge}). Case-insensitive.
|
|
17
|
+
*/
|
|
18
|
+
export function appGlyphForSlug(slug) {
|
|
19
|
+
const key = slug.replace(/^skip/i, "").toLowerCase();
|
|
20
|
+
return APP_GLYPHS.includes(key) ? key : null;
|
|
21
|
+
}
|
|
2
22
|
// Exact 24×24 glyph paths (design_handoff_app_glyphs — the finalized round that
|
|
3
23
|
// fixed racquetball, trips, and flow and added the guide mark). `color` only
|
|
4
24
|
// matters for filled sub-shapes (e.g. the racquetball ball), which are filled
|
package/dist/ProfileMenu.js
CHANGED
|
@@ -2,6 +2,7 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
|
|
|
2
2
|
import { useEffect, useRef, useState } from "react";
|
|
3
3
|
import { CircleUser, LogIn, LogOut, Settings } from "lucide-react";
|
|
4
4
|
import { AppBadge } from "./AppBadge.js";
|
|
5
|
+
import { AppLogo, appGlyphForSlug } from "./AppLogo.js";
|
|
5
6
|
import { SKIPLEAGUE_ACCOUNT_URL, SKIPLEAGUE_APPS } from "./apps.js";
|
|
6
7
|
/**
|
|
7
8
|
* The canonical SkipLeague account control: a boxed user-icon button that opens
|
|
@@ -66,7 +67,9 @@ export function ProfileMenu({ user, currentSlug, apps = SKIPLEAGUE_APPS, enabled
|
|
|
66
67
|
transition: "border-color 0.15s, color 0.15s",
|
|
67
68
|
}, children: _jsx(CircleUser, { size: 22 }) }), open && (_jsx("div", { role: "menu", style: menuStyle, children: showSignedOut ? (_jsxs(_Fragment, { children: [_jsx("div", { style: { padding: "0.5rem 0.75rem", borderBottom: "1px solid var(--skl-color-border)" }, children: _jsx("div", { style: { fontWeight: 600, fontSize: "var(--skl-text-sm)", color: "var(--skl-color-text)" }, children: "Not signed in" }) }), _jsxs("button", { role: "menuitem", onClick: onSignIn, style: { ...itemStyle, width: "100%", textAlign: "left", background: "none", border: "none", cursor: "pointer" }, children: [_jsx(LogIn, { size: 15 }), " ", signInLabel] })] })) : (_jsxs(_Fragment, { children: [_jsxs("div", { style: { padding: "0.5rem 0.75rem", borderBottom: "1px solid var(--skl-color-border)" }, children: [_jsx("div", { style: { fontWeight: 600, fontSize: "var(--skl-text-sm)", color: "var(--skl-color-text)" }, children: label }), user?.email && _jsx("div", { style: { fontSize: "var(--skl-text-xs)", color: "var(--skl-color-text-muted)" }, children: user.email })] }), visibleApps.length > 0 && (_jsxs(_Fragment, { children: [_jsx("div", { style: switchHeading, children: "Switch app" }), visibleApps.map((a) => {
|
|
68
69
|
const isCurrent = a.slug === currentSlug;
|
|
69
|
-
|
|
70
|
+
// Per-app glyph when one exists; letter badge otherwise.
|
|
71
|
+
const glyph = appGlyphForSlug(a.slug);
|
|
72
|
+
const inner = (_jsxs("span", { style: { display: "flex", alignItems: "center", gap: "0.5rem" }, children: [glyph ? _jsx(AppLogo, { app: glyph, size: 22 }) : _jsx(AppBadge, { name: a.name }), a.name] }));
|
|
70
73
|
// The app you're in: light-green highlight, not clickable.
|
|
71
74
|
return isCurrent ? (_jsx("div", { "aria-current": "page", style: { ...itemStyle, fontWeight: 600, background: "var(--skl-color-current-bg)", color: "var(--skl-color-current-text)", cursor: "default" }, children: inner }, a.slug)) : (_jsx("a", { href: a.url, role: "menuitem", style: itemStyle, children: inner }, a.slug));
|
|
72
75
|
})] })), _jsx("div", { style: divider }), accountItem, _jsx("div", { style: divider }), _jsxs("button", { role: "menuitem", onClick: onSignOut, style: { ...itemStyle, width: "100%", textAlign: "left", background: "none", border: "none", cursor: "pointer" }, children: [_jsx(LogOut, { size: 15 }), " Sign out"] })] })) }))] }));
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { type CSSProperties, type ReactNode } from "react";
|
|
2
|
+
/** A single navigation row in the desktop sidebar. */
|
|
3
|
+
export interface SidebarNavItem {
|
|
4
|
+
/** Stable key (also used for React reconciliation). */
|
|
5
|
+
key: string;
|
|
6
|
+
label: string;
|
|
7
|
+
/** ~18px line icon (stroke 2, currentColor) — inherits the row's color. */
|
|
8
|
+
icon?: ReactNode;
|
|
9
|
+
/** Renders an `<a href>`; omit for a plain `<button>` (e.g. pure onClick). */
|
|
10
|
+
href?: string;
|
|
11
|
+
/** Current route — highlighted. Derive from your router. */
|
|
12
|
+
active?: boolean;
|
|
13
|
+
onClick?: () => void;
|
|
14
|
+
}
|
|
15
|
+
/** A run of items under an optional uppercase heading (e.g. "LISTS"). */
|
|
16
|
+
export interface SidebarNavSection {
|
|
17
|
+
/** Uppercase section heading. Omit for an ungrouped run of items. */
|
|
18
|
+
heading?: string;
|
|
19
|
+
items: SidebarNavItem[];
|
|
20
|
+
}
|
|
21
|
+
/** Args passed to a custom `renderLink` so apps can route with their own `<Link>`. */
|
|
22
|
+
export interface SidebarNavLinkArgs {
|
|
23
|
+
href: string;
|
|
24
|
+
style?: CSSProperties;
|
|
25
|
+
"aria-current"?: "page";
|
|
26
|
+
children: ReactNode;
|
|
27
|
+
}
|
|
28
|
+
export interface SidebarNavProps {
|
|
29
|
+
/** Sections rendered top→bottom; each an optional heading + its items. */
|
|
30
|
+
sections: SidebarNavSection[];
|
|
31
|
+
/** Sidebar width in px (default 210). */
|
|
32
|
+
width?: number;
|
|
33
|
+
/**
|
|
34
|
+
* Render item links via your own router (e.g. React Router's `<Link>`) instead
|
|
35
|
+
* of a full-page `<a href>`. Example:
|
|
36
|
+
* `renderLink={({ href, ...p }) => <Link to={href} {...p} />}`.
|
|
37
|
+
*/
|
|
38
|
+
renderLink?: (args: SidebarNavLinkArgs) => ReactNode;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* The desktop left navigation sidebar (design_handoff_desktop_action_bar).
|
|
42
|
+
*
|
|
43
|
+
* Sits BELOW the shared app bar ({@link TopBar}/`DesktopActionBar`), never
|
|
44
|
+
* beside it. Starts directly with nav items — it carries NO app-name heading;
|
|
45
|
+
* the app name lives only in the bar. Desktop-only: hide it below the app's
|
|
46
|
+
* desktop breakpoint and rely on the app's bottom tab bar instead.
|
|
47
|
+
*
|
|
48
|
+
* Which item is `active` is owned by the app's router, not this component.
|
|
49
|
+
*/
|
|
50
|
+
export declare function SidebarNav({ sections, width, renderLink }: SidebarNavProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
/**
|
|
4
|
+
* The desktop left navigation sidebar (design_handoff_desktop_action_bar).
|
|
5
|
+
*
|
|
6
|
+
* Sits BELOW the shared app bar ({@link TopBar}/`DesktopActionBar`), never
|
|
7
|
+
* beside it. Starts directly with nav items — it carries NO app-name heading;
|
|
8
|
+
* the app name lives only in the bar. Desktop-only: hide it below the app's
|
|
9
|
+
* desktop breakpoint and rely on the app's bottom tab bar instead.
|
|
10
|
+
*
|
|
11
|
+
* Which item is `active` is owned by the app's router, not this component.
|
|
12
|
+
*/
|
|
13
|
+
export function SidebarNav({ sections, width = 210, renderLink }) {
|
|
14
|
+
return (_jsx("nav", { style: {
|
|
15
|
+
width,
|
|
16
|
+
flex: "0 0 auto",
|
|
17
|
+
borderRight: "1px solid var(--skl-color-border)",
|
|
18
|
+
padding: "14px 12px",
|
|
19
|
+
background: "#ffffff",
|
|
20
|
+
fontFamily: "var(--skl-font-sans)",
|
|
21
|
+
}, children: sections.map((section, si) => (_jsxs("div", { children: [section.heading && _jsx("div", { style: headingStyle, children: section.heading }), section.items.map((item) => (_jsx(Item, { item: item, renderLink: renderLink }, item.key)))] }, section.heading ?? `s${si}`))) }));
|
|
22
|
+
}
|
|
23
|
+
function Item({ item, renderLink, }) {
|
|
24
|
+
const [hover, setHover] = useState(false);
|
|
25
|
+
const { label, icon, href, active, onClick } = item;
|
|
26
|
+
const style = {
|
|
27
|
+
display: "flex",
|
|
28
|
+
alignItems: "center",
|
|
29
|
+
gap: 11,
|
|
30
|
+
padding: "9px 11px",
|
|
31
|
+
borderRadius: 9,
|
|
32
|
+
fontWeight: 600,
|
|
33
|
+
fontSize: 14,
|
|
34
|
+
textDecoration: "none",
|
|
35
|
+
cursor: "pointer",
|
|
36
|
+
// Active wins; otherwise a subtle hover fill for affordance.
|
|
37
|
+
background: active ? "var(--skl-color-current-bg)" : hover ? "var(--skl-color-surface-muted)" : "transparent",
|
|
38
|
+
color: active ? "var(--skl-color-brand)" : "#475569",
|
|
39
|
+
};
|
|
40
|
+
const inner = (_jsxs(_Fragment, { children: [icon, label] }));
|
|
41
|
+
const hoverHandlers = {
|
|
42
|
+
onMouseEnter: () => setHover(true),
|
|
43
|
+
onMouseLeave: () => setHover(false),
|
|
44
|
+
};
|
|
45
|
+
// Link row (href) — use the app's router when renderLink is supplied.
|
|
46
|
+
if (href) {
|
|
47
|
+
if (renderLink) {
|
|
48
|
+
return (_jsx("div", { ...hoverHandlers, children: renderLink({ href, style, "aria-current": active ? "page" : undefined, children: inner }) }));
|
|
49
|
+
}
|
|
50
|
+
return (_jsx("a", { href: href, style: style, "aria-current": active ? "page" : undefined, onClick: onClick, ...hoverHandlers, children: inner }));
|
|
51
|
+
}
|
|
52
|
+
// Button row (no href) — pure onClick.
|
|
53
|
+
return (_jsx("button", { type: "button", onClick: onClick, "aria-current": active ? "page" : undefined, style: { ...style, width: "100%", textAlign: "left", border: "none" }, ...hoverHandlers, children: inner }));
|
|
54
|
+
}
|
|
55
|
+
const headingStyle = {
|
|
56
|
+
fontWeight: 700,
|
|
57
|
+
fontSize: 10,
|
|
58
|
+
textTransform: "uppercase",
|
|
59
|
+
letterSpacing: 0.7,
|
|
60
|
+
color: "var(--skl-color-text-faint)",
|
|
61
|
+
padding: "14px 11px 5px",
|
|
62
|
+
};
|
package/dist/TopBar.d.ts
CHANGED
|
@@ -1,30 +1,61 @@
|
|
|
1
1
|
import { type ButtonHTMLAttributes, type ReactNode } from "react";
|
|
2
2
|
import { type AppGlyph } from "./AppLogo.js";
|
|
3
3
|
import { type ProfileMenuProps } from "./ProfileMenu.js";
|
|
4
|
+
/** Bar surface tone: `dark` = the green header (default), `light` = white bar. */
|
|
5
|
+
export type TopBarTone = "dark" | "light";
|
|
4
6
|
/**
|
|
5
|
-
* The shared SkipLeague
|
|
7
|
+
* The shared SkipLeague app bar (design_handoff_top_bar → design_handoff_desktop_action_bar).
|
|
8
|
+
* Also exported as `DesktopActionBar` — the same component under the newer name;
|
|
9
|
+
* migrate imports opportunistically and drop the alias once nothing uses `TopBar`.
|
|
6
10
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
11
|
+
* ONE bar for every app + breakpoint. It spans the full top row on both mobile
|
|
12
|
+
* and desktop; on desktop the app adds a left sidebar (see {@link SidebarNav})
|
|
13
|
+
* BELOW the bar — the bar itself is unchanged. Layout is identical across apps;
|
|
14
|
+
* only the logo glyph, wordmark, tone, and which actions are enabled change:
|
|
15
|
+
*
|
|
16
|
+
* [AppLogo + wordmark] ........ [ search inbox members share overflow ][ actions ][ ProfileMenu ]
|
|
10
17
|
*
|
|
11
18
|
* - ProfileMenu is pinned to the far right on every page (the persistent slot).
|
|
12
|
-
* -
|
|
13
|
-
*
|
|
14
|
-
*
|
|
19
|
+
* - The named action toggles render in a FIXED left→right order
|
|
20
|
+
* (search → inbox → members → share → overflow), each only when its `show*`
|
|
21
|
+
* flag is set, so the same action looks identical in every app.
|
|
22
|
+
* - `actions` is an escape hatch for one-off contextual buttons; render zero or
|
|
23
|
+
* more {@link TopBarIconButton}s. They sit between the named cluster and the
|
|
24
|
+
* ProfileMenu.
|
|
25
|
+
* - The app name appears ONLY here, never repeated in the sidebar.
|
|
15
26
|
*/
|
|
16
|
-
export declare function TopBar({ app, appName, actions, compact, ...profile }: {
|
|
27
|
+
export declare function TopBar({ app, appName, tone, actions, compact, showSearch, showInbox, showMembers, showShare, showOverflow, onSearch, onInbox, onMembers, onShare, onOverflow, ...profile }: {
|
|
17
28
|
app: AppGlyph;
|
|
18
29
|
appName: string;
|
|
19
|
-
/**
|
|
30
|
+
/** Bar surface tone. `dark` (green header) is the default. */
|
|
31
|
+
tone?: TopBarTone;
|
|
32
|
+
/** One-off contextual buttons, rendered between the named cluster and ProfileMenu. */
|
|
20
33
|
actions?: ReactNode;
|
|
21
|
-
/** Mobile sizing (
|
|
34
|
+
/** Mobile sizing (52px bar) vs the default 56px. */
|
|
22
35
|
compact?: boolean;
|
|
36
|
+
/** Named action toggles — each renders a ghost icon button when true, in fixed order. */
|
|
37
|
+
showSearch?: boolean;
|
|
38
|
+
showInbox?: boolean;
|
|
39
|
+
showMembers?: boolean;
|
|
40
|
+
showShare?: boolean;
|
|
41
|
+
showOverflow?: boolean;
|
|
42
|
+
onSearch?: () => void;
|
|
43
|
+
onInbox?: () => void;
|
|
44
|
+
onMembers?: () => void;
|
|
45
|
+
onShare?: () => void;
|
|
46
|
+
onOverflow?: () => void;
|
|
23
47
|
} & Pick<ProfileMenuProps, "user" | "currentSlug" | "apps" | "enabledSlugs" | "accountUrl" | "onSignOut" | "renderLink" | "onSignIn">): import("react").JSX.Element;
|
|
24
48
|
/**
|
|
25
|
-
* A
|
|
26
|
-
*
|
|
49
|
+
* A ghost (borderless-fill) icon button for the app bar — the named actions use
|
|
50
|
+
* it, and apps pass their own for one-off `actions`. Give it a 17px line icon
|
|
51
|
+
* (stroke 2, currentColor) as children. Styling tracks the bar `tone`.
|
|
27
52
|
*/
|
|
28
|
-
export declare function TopBarIconButton({ compact, style, children, ...props }: ButtonHTMLAttributes<HTMLButtonElement> & {
|
|
53
|
+
export declare function TopBarIconButton({ tone, compact, style, children, ...props }: ButtonHTMLAttributes<HTMLButtonElement> & {
|
|
54
|
+
tone?: TopBarTone;
|
|
29
55
|
compact?: boolean;
|
|
30
56
|
}): import("react").JSX.Element;
|
|
57
|
+
/**
|
|
58
|
+
* Alias for {@link TopBar} under its platform-app-bar name. Same component,
|
|
59
|
+
* same props — use whichever import name the app standardizes on.
|
|
60
|
+
*/
|
|
61
|
+
export { TopBar as DesktopActionBar };
|
package/dist/TopBar.js
CHANGED
|
@@ -1,45 +1,78 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useState } from "react";
|
|
3
|
+
import { Inbox, MoreHorizontal, Search, Upload, Users } from "lucide-react";
|
|
3
4
|
import { AppLogo } from "./AppLogo.js";
|
|
4
5
|
import { ProfileMenu } from "./ProfileMenu.js";
|
|
5
6
|
/**
|
|
6
|
-
* The shared SkipLeague
|
|
7
|
+
* The shared SkipLeague app bar (design_handoff_top_bar → design_handoff_desktop_action_bar).
|
|
8
|
+
* Also exported as `DesktopActionBar` — the same component under the newer name;
|
|
9
|
+
* migrate imports opportunistically and drop the alias once nothing uses `TopBar`.
|
|
7
10
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
+
* ONE bar for every app + breakpoint. It spans the full top row on both mobile
|
|
12
|
+
* and desktop; on desktop the app adds a left sidebar (see {@link SidebarNav})
|
|
13
|
+
* BELOW the bar — the bar itself is unchanged. Layout is identical across apps;
|
|
14
|
+
* only the logo glyph, wordmark, tone, and which actions are enabled change:
|
|
15
|
+
*
|
|
16
|
+
* [AppLogo + wordmark] ........ [ search inbox members share overflow ][ actions ][ ProfileMenu ]
|
|
11
17
|
*
|
|
12
18
|
* - ProfileMenu is pinned to the far right on every page (the persistent slot).
|
|
13
|
-
* -
|
|
14
|
-
*
|
|
15
|
-
*
|
|
19
|
+
* - The named action toggles render in a FIXED left→right order
|
|
20
|
+
* (search → inbox → members → share → overflow), each only when its `show*`
|
|
21
|
+
* flag is set, so the same action looks identical in every app.
|
|
22
|
+
* - `actions` is an escape hatch for one-off contextual buttons; render zero or
|
|
23
|
+
* more {@link TopBarIconButton}s. They sit between the named cluster and the
|
|
24
|
+
* ProfileMenu.
|
|
25
|
+
* - The app name appears ONLY here, never repeated in the sidebar.
|
|
16
26
|
*/
|
|
17
|
-
export function TopBar({ app, appName, actions, compact = false, ...profile }) {
|
|
27
|
+
export function TopBar({ app, appName, tone = "dark", actions, compact = false, showSearch = false, showInbox = false, showMembers = false, showShare = false, showOverflow = false, onSearch, onInbox, onMembers, onShare, onOverflow, ...profile }) {
|
|
28
|
+
const light = tone === "light";
|
|
18
29
|
const bar = {
|
|
19
30
|
display: "flex",
|
|
20
31
|
alignItems: "center",
|
|
21
|
-
|
|
22
|
-
height: compact ?
|
|
23
|
-
padding: compact ? "0 12px" : "0
|
|
24
|
-
background: "var(--skl-color-header-dark)",
|
|
32
|
+
gap: 12,
|
|
33
|
+
height: compact ? 52 : 56,
|
|
34
|
+
padding: compact ? "0 12px" : "0 16px",
|
|
35
|
+
background: light ? "#ffffff" : "var(--skl-color-header-dark)",
|
|
36
|
+
borderBottom: light ? "1px solid var(--skl-color-border)" : undefined,
|
|
37
|
+
fontFamily: "var(--skl-font-sans)",
|
|
25
38
|
};
|
|
26
|
-
|
|
27
|
-
|
|
39
|
+
// Named actions in their fixed left→right order; only the enabled ones render.
|
|
40
|
+
const named = [
|
|
41
|
+
showSearch && { key: "search", title: "Search", Icon: Search, onClick: onSearch },
|
|
42
|
+
showInbox && { key: "inbox", title: "Inbox", Icon: Inbox, onClick: onInbox },
|
|
43
|
+
showMembers && { key: "members", title: "Members", Icon: Users, onClick: onMembers },
|
|
44
|
+
showShare && { key: "share", title: "Share this view", Icon: Upload, onClick: onShare },
|
|
45
|
+
showOverflow && { key: "overflow", title: "More", Icon: MoreHorizontal, onClick: onOverflow },
|
|
46
|
+
].filter(Boolean);
|
|
47
|
+
return (_jsxs("div", { style: bar, children: [_jsxs("div", { style: { display: "flex", alignItems: "center", gap: 10, minWidth: 0, flex: "0 1 auto" }, children: [_jsx(AppLogo, { app: app, size: compact ? 26 : 27 }), _jsx("span", { style: {
|
|
28
48
|
fontWeight: 700,
|
|
29
49
|
fontSize: compact ? 14 : 15,
|
|
30
|
-
color: "var(--skl-color-on-dark)",
|
|
50
|
+
color: light ? "var(--skl-color-text)" : "var(--skl-color-on-dark)",
|
|
31
51
|
whiteSpace: "nowrap",
|
|
32
52
|
overflow: "hidden",
|
|
33
53
|
textOverflow: "ellipsis",
|
|
34
|
-
}, children: appName })] }), _jsxs("div", { style: { display: "flex", alignItems: "center", gap: 8 }, children: [
|
|
54
|
+
}, children: appName })] }), _jsx("div", { style: { flex: "1 1 auto", minWidth: 8 } }), _jsxs("div", { style: { display: "flex", alignItems: "center", gap: 8, flex: "0 0 auto" }, children: [named.map(({ key, title, Icon, onClick }) => (_jsx(TopBarIconButton, { tone: tone, compact: compact, title: title, "aria-label": title, onClick: onClick, children: _jsx(Icon, { size: 17 }) }, key))), actions, _jsx(ProfileMenu, { tone: tone, ...profile })] })] }));
|
|
35
55
|
}
|
|
36
56
|
/**
|
|
37
|
-
* A
|
|
38
|
-
*
|
|
57
|
+
* A ghost (borderless-fill) icon button for the app bar — the named actions use
|
|
58
|
+
* it, and apps pass their own for one-off `actions`. Give it a 17px line icon
|
|
59
|
+
* (stroke 2, currentColor) as children. Styling tracks the bar `tone`.
|
|
39
60
|
*/
|
|
40
|
-
export function TopBarIconButton({ compact = false, style, children, ...props }) {
|
|
61
|
+
export function TopBarIconButton({ tone = "dark", compact = false, style, children, ...props }) {
|
|
41
62
|
const [hover, setHover] = useState(false);
|
|
42
63
|
const dim = compact ? 36 : 38;
|
|
64
|
+
const light = tone === "light";
|
|
65
|
+
const palette = light
|
|
66
|
+
? {
|
|
67
|
+
border: hover ? "#cbd5e1" : "var(--skl-color-border)",
|
|
68
|
+
background: hover ? "var(--skl-color-surface-muted)" : "#ffffff",
|
|
69
|
+
color: hover ? "var(--skl-color-text)" : "var(--skl-color-text-muted)",
|
|
70
|
+
}
|
|
71
|
+
: {
|
|
72
|
+
border: hover ? "rgba(94,234,212,0.5)" : "rgba(255,255,255,0.16)",
|
|
73
|
+
background: hover ? "rgba(255,255,255,0.08)" : "transparent",
|
|
74
|
+
color: hover ? "var(--skl-color-brand-bright)" : "var(--skl-color-on-dark-muted)",
|
|
75
|
+
};
|
|
43
76
|
return (_jsx("button", { type: "button", onMouseEnter: () => setHover(true), onMouseLeave: () => setHover(false), style: {
|
|
44
77
|
width: dim,
|
|
45
78
|
height: dim,
|
|
@@ -48,10 +81,16 @@ export function TopBarIconButton({ compact = false, style, children, ...props })
|
|
|
48
81
|
alignItems: "center",
|
|
49
82
|
justifyContent: "center",
|
|
50
83
|
cursor: "pointer",
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
84
|
+
flex: "0 0 auto",
|
|
85
|
+
background: palette.background,
|
|
86
|
+
border: `1px solid ${palette.border}`,
|
|
87
|
+
color: palette.color,
|
|
88
|
+
transition: "background .14s, border-color .14s, color .14s",
|
|
55
89
|
...style,
|
|
56
90
|
}, ...props, children: children }));
|
|
57
91
|
}
|
|
92
|
+
/**
|
|
93
|
+
* Alias for {@link TopBar} under its platform-app-bar name. Same component,
|
|
94
|
+
* same props — use whichever import name the app standardizes on.
|
|
95
|
+
*/
|
|
96
|
+
export { TopBar as DesktopActionBar };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
export { ProfileMenu } from "./ProfileMenu.js";
|
|
2
2
|
export type { ProfileMenuProps, ProfileMenuUser, ProfileMenuLinkArgs } from "./ProfileMenu.js";
|
|
3
3
|
export { AppBadge } from "./AppBadge.js";
|
|
4
|
-
export { AppLogo } from "./AppLogo.js";
|
|
4
|
+
export { AppLogo, APP_GLYPHS, appGlyphForSlug } from "./AppLogo.js";
|
|
5
5
|
export type { AppGlyph } from "./AppLogo.js";
|
|
6
|
-
export { TopBar, TopBarIconButton } from "./TopBar.js";
|
|
6
|
+
export { TopBar, TopBarIconButton, DesktopActionBar } from "./TopBar.js";
|
|
7
|
+
export type { TopBarTone } from "./TopBar.js";
|
|
8
|
+
export { SidebarNav } from "./SidebarNav.js";
|
|
9
|
+
export type { SidebarNavProps, SidebarNavSection, SidebarNavItem, SidebarNavLinkArgs } from "./SidebarNav.js";
|
|
7
10
|
export { BottomNav } from "./BottomNav.js";
|
|
8
11
|
export type { BottomNavProps, BottomNavTab, BottomNavAction } from "./BottomNav.js";
|
|
9
12
|
export { ShareMenu } from "./ShareMenu.js";
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
export { ProfileMenu } from "./ProfileMenu.js";
|
|
2
2
|
export { AppBadge } from "./AppBadge.js";
|
|
3
|
-
export { AppLogo } from "./AppLogo.js";
|
|
4
|
-
export { TopBar, TopBarIconButton } from "./TopBar.js";
|
|
3
|
+
export { AppLogo, APP_GLYPHS, appGlyphForSlug } from "./AppLogo.js";
|
|
4
|
+
export { TopBar, TopBarIconButton, DesktopActionBar } from "./TopBar.js";
|
|
5
|
+
export { SidebarNav } from "./SidebarNav.js";
|
|
5
6
|
export { BottomNav } from "./BottomNav.js";
|
|
6
7
|
export { ShareMenu } from "./ShareMenu.js";
|
|
7
8
|
export { Button } from "./Button.js";
|
package/package.json
CHANGED
package/src/AppLogo.tsx
CHANGED
|
@@ -11,6 +11,28 @@ export type AppGlyph =
|
|
|
11
11
|
| "guide"
|
|
12
12
|
| "flow";
|
|
13
13
|
|
|
14
|
+
/** Every app that has a glyph, for slug→glyph resolution and fallbacks. */
|
|
15
|
+
export const APP_GLYPHS: readonly AppGlyph[] = [
|
|
16
|
+
"lists",
|
|
17
|
+
"racquetball",
|
|
18
|
+
"trips",
|
|
19
|
+
"gifts",
|
|
20
|
+
"reading",
|
|
21
|
+
"today",
|
|
22
|
+
"guide",
|
|
23
|
+
"flow",
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Resolve a platform app slug (e.g. `"skipracquetball"`) to its {@link AppGlyph}
|
|
28
|
+
* by dropping the `skip` prefix, or `null` when no glyph exists for it (so a
|
|
29
|
+
* caller can fall back to a letter {@link AppBadge}). Case-insensitive.
|
|
30
|
+
*/
|
|
31
|
+
export function appGlyphForSlug(slug: string): AppGlyph | null {
|
|
32
|
+
const key = slug.replace(/^skip/i, "").toLowerCase();
|
|
33
|
+
return (APP_GLYPHS as readonly string[]).includes(key) ? (key as AppGlyph) : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
14
36
|
// Exact 24×24 glyph paths (design_handoff_app_glyphs — the finalized round that
|
|
15
37
|
// fixed racquetball, trips, and flow and added the guide mark). `color` only
|
|
16
38
|
// matters for filled sub-shapes (e.g. the racquetball ball), which are filled
|
package/src/ProfileMenu.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
|
2
2
|
import { CircleUser, LogIn, LogOut, Settings } from "lucide-react";
|
|
3
3
|
import { AppBadge } from "./AppBadge.js";
|
|
4
|
+
import { AppLogo, appGlyphForSlug } from "./AppLogo.js";
|
|
4
5
|
import { SKIPLEAGUE_ACCOUNT_URL, SKIPLEAGUE_APPS, type AppLink } from "./apps.js";
|
|
5
6
|
|
|
6
7
|
export interface ProfileMenuUser {
|
|
@@ -202,9 +203,11 @@ export function ProfileMenu({
|
|
|
202
203
|
<div style={switchHeading}>Switch app</div>
|
|
203
204
|
{visibleApps.map((a) => {
|
|
204
205
|
const isCurrent = a.slug === currentSlug;
|
|
206
|
+
// Per-app glyph when one exists; letter badge otherwise.
|
|
207
|
+
const glyph = appGlyphForSlug(a.slug);
|
|
205
208
|
const inner = (
|
|
206
209
|
<span style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
|
207
|
-
<AppBadge name={a.name} />
|
|
210
|
+
{glyph ? <AppLogo app={glyph} size={22} /> : <AppBadge name={a.name} />}
|
|
208
211
|
{a.name}
|
|
209
212
|
</span>
|
|
210
213
|
);
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { useState, type CSSProperties, type ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
/** A single navigation row in the desktop sidebar. */
|
|
4
|
+
export interface SidebarNavItem {
|
|
5
|
+
/** Stable key (also used for React reconciliation). */
|
|
6
|
+
key: string;
|
|
7
|
+
label: string;
|
|
8
|
+
/** ~18px line icon (stroke 2, currentColor) — inherits the row's color. */
|
|
9
|
+
icon?: ReactNode;
|
|
10
|
+
/** Renders an `<a href>`; omit for a plain `<button>` (e.g. pure onClick). */
|
|
11
|
+
href?: string;
|
|
12
|
+
/** Current route — highlighted. Derive from your router. */
|
|
13
|
+
active?: boolean;
|
|
14
|
+
onClick?: () => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** A run of items under an optional uppercase heading (e.g. "LISTS"). */
|
|
18
|
+
export interface SidebarNavSection {
|
|
19
|
+
/** Uppercase section heading. Omit for an ungrouped run of items. */
|
|
20
|
+
heading?: string;
|
|
21
|
+
items: SidebarNavItem[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Args passed to a custom `renderLink` so apps can route with their own `<Link>`. */
|
|
25
|
+
export interface SidebarNavLinkArgs {
|
|
26
|
+
href: string;
|
|
27
|
+
style?: CSSProperties;
|
|
28
|
+
"aria-current"?: "page";
|
|
29
|
+
children: ReactNode;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface SidebarNavProps {
|
|
33
|
+
/** Sections rendered top→bottom; each an optional heading + its items. */
|
|
34
|
+
sections: SidebarNavSection[];
|
|
35
|
+
/** Sidebar width in px (default 210). */
|
|
36
|
+
width?: number;
|
|
37
|
+
/**
|
|
38
|
+
* Render item links via your own router (e.g. React Router's `<Link>`) instead
|
|
39
|
+
* of a full-page `<a href>`. Example:
|
|
40
|
+
* `renderLink={({ href, ...p }) => <Link to={href} {...p} />}`.
|
|
41
|
+
*/
|
|
42
|
+
renderLink?: (args: SidebarNavLinkArgs) => ReactNode;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The desktop left navigation sidebar (design_handoff_desktop_action_bar).
|
|
47
|
+
*
|
|
48
|
+
* Sits BELOW the shared app bar ({@link TopBar}/`DesktopActionBar`), never
|
|
49
|
+
* beside it. Starts directly with nav items — it carries NO app-name heading;
|
|
50
|
+
* the app name lives only in the bar. Desktop-only: hide it below the app's
|
|
51
|
+
* desktop breakpoint and rely on the app's bottom tab bar instead.
|
|
52
|
+
*
|
|
53
|
+
* Which item is `active` is owned by the app's router, not this component.
|
|
54
|
+
*/
|
|
55
|
+
export function SidebarNav({ sections, width = 210, renderLink }: SidebarNavProps) {
|
|
56
|
+
return (
|
|
57
|
+
<nav
|
|
58
|
+
style={{
|
|
59
|
+
width,
|
|
60
|
+
flex: "0 0 auto",
|
|
61
|
+
borderRight: "1px solid var(--skl-color-border)",
|
|
62
|
+
padding: "14px 12px",
|
|
63
|
+
background: "#ffffff",
|
|
64
|
+
fontFamily: "var(--skl-font-sans)",
|
|
65
|
+
}}
|
|
66
|
+
>
|
|
67
|
+
{sections.map((section, si) => (
|
|
68
|
+
<div key={section.heading ?? `s${si}`}>
|
|
69
|
+
{section.heading && <div style={headingStyle}>{section.heading}</div>}
|
|
70
|
+
{section.items.map((item) => (
|
|
71
|
+
<Item key={item.key} item={item} renderLink={renderLink} />
|
|
72
|
+
))}
|
|
73
|
+
</div>
|
|
74
|
+
))}
|
|
75
|
+
</nav>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function Item({
|
|
80
|
+
item,
|
|
81
|
+
renderLink,
|
|
82
|
+
}: {
|
|
83
|
+
item: SidebarNavItem;
|
|
84
|
+
renderLink?: (args: SidebarNavLinkArgs) => ReactNode;
|
|
85
|
+
}) {
|
|
86
|
+
const [hover, setHover] = useState(false);
|
|
87
|
+
const { label, icon, href, active, onClick } = item;
|
|
88
|
+
|
|
89
|
+
const style: CSSProperties = {
|
|
90
|
+
display: "flex",
|
|
91
|
+
alignItems: "center",
|
|
92
|
+
gap: 11,
|
|
93
|
+
padding: "9px 11px",
|
|
94
|
+
borderRadius: 9,
|
|
95
|
+
fontWeight: 600,
|
|
96
|
+
fontSize: 14,
|
|
97
|
+
textDecoration: "none",
|
|
98
|
+
cursor: "pointer",
|
|
99
|
+
// Active wins; otherwise a subtle hover fill for affordance.
|
|
100
|
+
background: active ? "var(--skl-color-current-bg)" : hover ? "var(--skl-color-surface-muted)" : "transparent",
|
|
101
|
+
color: active ? "var(--skl-color-brand)" : "#475569",
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const inner = (
|
|
105
|
+
<>
|
|
106
|
+
{icon}
|
|
107
|
+
{label}
|
|
108
|
+
</>
|
|
109
|
+
);
|
|
110
|
+
const hoverHandlers = {
|
|
111
|
+
onMouseEnter: () => setHover(true),
|
|
112
|
+
onMouseLeave: () => setHover(false),
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
// Link row (href) — use the app's router when renderLink is supplied.
|
|
116
|
+
if (href) {
|
|
117
|
+
if (renderLink) {
|
|
118
|
+
return (
|
|
119
|
+
<div {...hoverHandlers}>
|
|
120
|
+
{renderLink({ href, style, "aria-current": active ? "page" : undefined, children: inner })}
|
|
121
|
+
</div>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
return (
|
|
125
|
+
<a href={href} style={style} aria-current={active ? "page" : undefined} onClick={onClick} {...hoverHandlers}>
|
|
126
|
+
{inner}
|
|
127
|
+
</a>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Button row (no href) — pure onClick.
|
|
132
|
+
return (
|
|
133
|
+
<button
|
|
134
|
+
type="button"
|
|
135
|
+
onClick={onClick}
|
|
136
|
+
aria-current={active ? "page" : undefined}
|
|
137
|
+
style={{ ...style, width: "100%", textAlign: "left", border: "none" }}
|
|
138
|
+
{...hoverHandlers}
|
|
139
|
+
>
|
|
140
|
+
{inner}
|
|
141
|
+
</button>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const headingStyle: CSSProperties = {
|
|
146
|
+
fontWeight: 700,
|
|
147
|
+
fontSize: 10,
|
|
148
|
+
textTransform: "uppercase",
|
|
149
|
+
letterSpacing: 0.7,
|
|
150
|
+
color: "var(--skl-color-text-faint)",
|
|
151
|
+
padding: "14px 11px 5px",
|
|
152
|
+
};
|
package/src/TopBar.tsx
CHANGED
|
@@ -1,55 +1,105 @@
|
|
|
1
1
|
import { useState, type ButtonHTMLAttributes, type CSSProperties, type ReactNode } from "react";
|
|
2
|
+
import { Inbox, MoreHorizontal, Search, Upload, Users, type LucideIcon } from "lucide-react";
|
|
2
3
|
|
|
3
4
|
import { AppLogo, type AppGlyph } from "./AppLogo.js";
|
|
4
5
|
import { ProfileMenu, type ProfileMenuProps } from "./ProfileMenu.js";
|
|
5
6
|
|
|
7
|
+
/** Bar surface tone: `dark` = the green header (default), `light` = white bar. */
|
|
8
|
+
export type TopBarTone = "dark" | "light";
|
|
9
|
+
|
|
6
10
|
/**
|
|
7
|
-
* The shared SkipLeague
|
|
11
|
+
* The shared SkipLeague app bar (design_handoff_top_bar → design_handoff_desktop_action_bar).
|
|
12
|
+
* Also exported as `DesktopActionBar` — the same component under the newer name;
|
|
13
|
+
* migrate imports opportunistically and drop the alias once nothing uses `TopBar`.
|
|
14
|
+
*
|
|
15
|
+
* ONE bar for every app + breakpoint. It spans the full top row on both mobile
|
|
16
|
+
* and desktop; on desktop the app adds a left sidebar (see {@link SidebarNav})
|
|
17
|
+
* BELOW the bar — the bar itself is unchanged. Layout is identical across apps;
|
|
18
|
+
* only the logo glyph, wordmark, tone, and which actions are enabled change:
|
|
8
19
|
*
|
|
9
|
-
*
|
|
10
|
-
* contextual action slot change:
|
|
11
|
-
* [AppLogo + wordmark] ........ [ ...actions ][ ProfileMenu ]
|
|
20
|
+
* [AppLogo + wordmark] ........ [ search inbox members share overflow ][ actions ][ ProfileMenu ]
|
|
12
21
|
*
|
|
13
22
|
* - ProfileMenu is pinned to the far right on every page (the persistent slot).
|
|
14
|
-
* -
|
|
15
|
-
*
|
|
16
|
-
*
|
|
23
|
+
* - The named action toggles render in a FIXED left→right order
|
|
24
|
+
* (search → inbox → members → share → overflow), each only when its `show*`
|
|
25
|
+
* flag is set, so the same action looks identical in every app.
|
|
26
|
+
* - `actions` is an escape hatch for one-off contextual buttons; render zero or
|
|
27
|
+
* more {@link TopBarIconButton}s. They sit between the named cluster and the
|
|
28
|
+
* ProfileMenu.
|
|
29
|
+
* - The app name appears ONLY here, never repeated in the sidebar.
|
|
17
30
|
*/
|
|
18
31
|
export function TopBar({
|
|
19
32
|
app,
|
|
20
33
|
appName,
|
|
34
|
+
tone = "dark",
|
|
21
35
|
actions,
|
|
22
36
|
compact = false,
|
|
37
|
+
showSearch = false,
|
|
38
|
+
showInbox = false,
|
|
39
|
+
showMembers = false,
|
|
40
|
+
showShare = false,
|
|
41
|
+
showOverflow = false,
|
|
42
|
+
onSearch,
|
|
43
|
+
onInbox,
|
|
44
|
+
onMembers,
|
|
45
|
+
onShare,
|
|
46
|
+
onOverflow,
|
|
23
47
|
...profile
|
|
24
48
|
}: {
|
|
25
49
|
app: AppGlyph;
|
|
26
50
|
appName: string;
|
|
27
|
-
/**
|
|
51
|
+
/** Bar surface tone. `dark` (green header) is the default. */
|
|
52
|
+
tone?: TopBarTone;
|
|
53
|
+
/** One-off contextual buttons, rendered between the named cluster and ProfileMenu. */
|
|
28
54
|
actions?: ReactNode;
|
|
29
|
-
/** Mobile sizing (
|
|
55
|
+
/** Mobile sizing (52px bar) vs the default 56px. */
|
|
30
56
|
compact?: boolean;
|
|
57
|
+
/** Named action toggles — each renders a ghost icon button when true, in fixed order. */
|
|
58
|
+
showSearch?: boolean;
|
|
59
|
+
showInbox?: boolean;
|
|
60
|
+
showMembers?: boolean;
|
|
61
|
+
showShare?: boolean;
|
|
62
|
+
showOverflow?: boolean;
|
|
63
|
+
onSearch?: () => void;
|
|
64
|
+
onInbox?: () => void;
|
|
65
|
+
onMembers?: () => void;
|
|
66
|
+
onShare?: () => void;
|
|
67
|
+
onOverflow?: () => void;
|
|
31
68
|
} & Pick<
|
|
32
69
|
ProfileMenuProps,
|
|
33
70
|
"user" | "currentSlug" | "apps" | "enabledSlugs" | "accountUrl" | "onSignOut" | "renderLink" | "onSignIn"
|
|
34
71
|
>) {
|
|
72
|
+
const light = tone === "light";
|
|
35
73
|
const bar: CSSProperties = {
|
|
36
74
|
display: "flex",
|
|
37
75
|
alignItems: "center",
|
|
38
|
-
|
|
39
|
-
height: compact ?
|
|
40
|
-
padding: compact ? "0 12px" : "0
|
|
41
|
-
background: "var(--skl-color-header-dark)",
|
|
76
|
+
gap: 12,
|
|
77
|
+
height: compact ? 52 : 56,
|
|
78
|
+
padding: compact ? "0 12px" : "0 16px",
|
|
79
|
+
background: light ? "#ffffff" : "var(--skl-color-header-dark)",
|
|
80
|
+
borderBottom: light ? "1px solid var(--skl-color-border)" : undefined,
|
|
81
|
+
fontFamily: "var(--skl-font-sans)",
|
|
42
82
|
};
|
|
83
|
+
|
|
84
|
+
// Named actions in their fixed left→right order; only the enabled ones render.
|
|
85
|
+
const named: Array<{ key: string; title: string; Icon: LucideIcon; onClick?: () => void }> = [
|
|
86
|
+
showSearch && { key: "search", title: "Search", Icon: Search, onClick: onSearch },
|
|
87
|
+
showInbox && { key: "inbox", title: "Inbox", Icon: Inbox, onClick: onInbox },
|
|
88
|
+
showMembers && { key: "members", title: "Members", Icon: Users, onClick: onMembers },
|
|
89
|
+
showShare && { key: "share", title: "Share this view", Icon: Upload, onClick: onShare },
|
|
90
|
+
showOverflow && { key: "overflow", title: "More", Icon: MoreHorizontal, onClick: onOverflow },
|
|
91
|
+
].filter(Boolean) as Array<{ key: string; title: string; Icon: LucideIcon; onClick?: () => void }>;
|
|
92
|
+
|
|
43
93
|
return (
|
|
44
94
|
<div style={bar}>
|
|
45
|
-
|
|
95
|
+
{/* Left — identity group. Shrinks and truncates before the action cluster does. */}
|
|
96
|
+
<div style={{ display: "flex", alignItems: "center", gap: 10, minWidth: 0, flex: "0 1 auto" }}>
|
|
46
97
|
<AppLogo app={app} size={compact ? 26 : 27} />
|
|
47
98
|
<span
|
|
48
99
|
style={{
|
|
49
|
-
fontFamily: "var(--skl-font-sans)",
|
|
50
100
|
fontWeight: 700,
|
|
51
101
|
fontSize: compact ? 14 : 15,
|
|
52
|
-
color: "var(--skl-color-on-dark)",
|
|
102
|
+
color: light ? "var(--skl-color-text)" : "var(--skl-color-on-dark)",
|
|
53
103
|
whiteSpace: "nowrap",
|
|
54
104
|
overflow: "hidden",
|
|
55
105
|
textOverflow: "ellipsis",
|
|
@@ -58,26 +108,52 @@ export function TopBar({
|
|
|
58
108
|
{appName}
|
|
59
109
|
</span>
|
|
60
110
|
</div>
|
|
61
|
-
|
|
111
|
+
|
|
112
|
+
{/* Flexible spacer. */}
|
|
113
|
+
<div style={{ flex: "1 1 auto", minWidth: 8 }} />
|
|
114
|
+
|
|
115
|
+
{/* Right — action cluster + persistent ProfileMenu. Never shrinks. */}
|
|
116
|
+
<div style={{ display: "flex", alignItems: "center", gap: 8, flex: "0 0 auto" }}>
|
|
117
|
+
{named.map(({ key, title, Icon, onClick }) => (
|
|
118
|
+
<TopBarIconButton key={key} tone={tone} compact={compact} title={title} aria-label={title} onClick={onClick}>
|
|
119
|
+
<Icon size={17} />
|
|
120
|
+
</TopBarIconButton>
|
|
121
|
+
))}
|
|
62
122
|
{actions}
|
|
63
|
-
<ProfileMenu tone=
|
|
123
|
+
<ProfileMenu tone={tone} {...profile} />
|
|
64
124
|
</div>
|
|
65
125
|
</div>
|
|
66
126
|
);
|
|
67
127
|
}
|
|
68
128
|
|
|
69
129
|
/**
|
|
70
|
-
* A
|
|
71
|
-
*
|
|
130
|
+
* A ghost (borderless-fill) icon button for the app bar — the named actions use
|
|
131
|
+
* it, and apps pass their own for one-off `actions`. Give it a 17px line icon
|
|
132
|
+
* (stroke 2, currentColor) as children. Styling tracks the bar `tone`.
|
|
72
133
|
*/
|
|
73
134
|
export function TopBarIconButton({
|
|
135
|
+
tone = "dark",
|
|
74
136
|
compact = false,
|
|
75
137
|
style,
|
|
76
138
|
children,
|
|
77
139
|
...props
|
|
78
|
-
}: ButtonHTMLAttributes<HTMLButtonElement> & { compact?: boolean }) {
|
|
140
|
+
}: ButtonHTMLAttributes<HTMLButtonElement> & { tone?: TopBarTone; compact?: boolean }) {
|
|
79
141
|
const [hover, setHover] = useState(false);
|
|
80
142
|
const dim = compact ? 36 : 38;
|
|
143
|
+
const light = tone === "light";
|
|
144
|
+
|
|
145
|
+
const palette = light
|
|
146
|
+
? {
|
|
147
|
+
border: hover ? "#cbd5e1" : "var(--skl-color-border)",
|
|
148
|
+
background: hover ? "var(--skl-color-surface-muted)" : "#ffffff",
|
|
149
|
+
color: hover ? "var(--skl-color-text)" : "var(--skl-color-text-muted)",
|
|
150
|
+
}
|
|
151
|
+
: {
|
|
152
|
+
border: hover ? "rgba(94,234,212,0.5)" : "rgba(255,255,255,0.16)",
|
|
153
|
+
background: hover ? "rgba(255,255,255,0.08)" : "transparent",
|
|
154
|
+
color: hover ? "var(--skl-color-brand-bright)" : "var(--skl-color-on-dark-muted)",
|
|
155
|
+
};
|
|
156
|
+
|
|
81
157
|
return (
|
|
82
158
|
<button
|
|
83
159
|
type="button"
|
|
@@ -91,10 +167,11 @@ export function TopBarIconButton({
|
|
|
91
167
|
alignItems: "center",
|
|
92
168
|
justifyContent: "center",
|
|
93
169
|
cursor: "pointer",
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
170
|
+
flex: "0 0 auto",
|
|
171
|
+
background: palette.background,
|
|
172
|
+
border: `1px solid ${palette.border}`,
|
|
173
|
+
color: palette.color,
|
|
174
|
+
transition: "background .14s, border-color .14s, color .14s",
|
|
98
175
|
...style,
|
|
99
176
|
}}
|
|
100
177
|
{...props}
|
|
@@ -103,3 +180,9 @@ export function TopBarIconButton({
|
|
|
103
180
|
</button>
|
|
104
181
|
);
|
|
105
182
|
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Alias for {@link TopBar} under its platform-app-bar name. Same component,
|
|
186
|
+
* same props — use whichever import name the app standardizes on.
|
|
187
|
+
*/
|
|
188
|
+
export { TopBar as DesktopActionBar };
|
package/src/index.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
export { ProfileMenu } from "./ProfileMenu.js";
|
|
2
2
|
export type { ProfileMenuProps, ProfileMenuUser, ProfileMenuLinkArgs } from "./ProfileMenu.js";
|
|
3
3
|
export { AppBadge } from "./AppBadge.js";
|
|
4
|
-
export { AppLogo } from "./AppLogo.js";
|
|
4
|
+
export { AppLogo, APP_GLYPHS, appGlyphForSlug } from "./AppLogo.js";
|
|
5
5
|
export type { AppGlyph } from "./AppLogo.js";
|
|
6
|
-
export { TopBar, TopBarIconButton } from "./TopBar.js";
|
|
6
|
+
export { TopBar, TopBarIconButton, DesktopActionBar } from "./TopBar.js";
|
|
7
|
+
export type { TopBarTone } from "./TopBar.js";
|
|
8
|
+
export { SidebarNav } from "./SidebarNav.js";
|
|
9
|
+
export type { SidebarNavProps, SidebarNavSection, SidebarNavItem, SidebarNavLinkArgs } from "./SidebarNav.js";
|
|
7
10
|
export { BottomNav } from "./BottomNav.js";
|
|
8
11
|
export type { BottomNavProps, BottomNavTab, BottomNavAction } from "./BottomNav.js";
|
|
9
12
|
export { ShareMenu } from "./ShareMenu.js";
|