@skipleague/design 0.4.4

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.
@@ -0,0 +1,146 @@
1
+ import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
2
+ import { Link2, Mail, MessageCircle, MoreHorizontal } from "lucide-react";
3
+
4
+ import { TopBarIconButton } from "./TopBar.js";
5
+
6
+ /**
7
+ * The top-bar Share dropdown (design_handoff_top_bar). A self-contained trigger
8
+ * (the upload glyph, styled like a {@link TopBarIconButton}) + an anchored menu —
9
+ * same open/dismiss model as ProfileMenu (click, outside-click, Escape).
10
+ *
11
+ * `noun` names the thing being shared ("players" → "Share players"). Pass the
12
+ * handlers you support; omitted items are hidden.
13
+ */
14
+ export function ShareMenu({
15
+ noun,
16
+ compact = false,
17
+ onCopyLink,
18
+ onMessages,
19
+ onEmail,
20
+ onMore,
21
+ }: {
22
+ noun: string;
23
+ compact?: boolean;
24
+ onCopyLink?: () => void;
25
+ onMessages?: () => void;
26
+ onEmail?: () => void;
27
+ onMore?: () => void;
28
+ }) {
29
+ const [open, setOpen] = useState(false);
30
+ const ref = useRef<HTMLDivElement | null>(null);
31
+
32
+ useEffect(() => {
33
+ if (!open) return;
34
+ const onDoc = (e: MouseEvent) => {
35
+ if (!ref.current?.contains(e.target as Node)) setOpen(false);
36
+ };
37
+ const onKey = (e: KeyboardEvent) => {
38
+ if (e.key === "Escape") setOpen(false);
39
+ };
40
+ window.addEventListener("mousedown", onDoc);
41
+ window.addEventListener("keydown", onKey);
42
+ return () => {
43
+ window.removeEventListener("mousedown", onDoc);
44
+ window.removeEventListener("keydown", onKey);
45
+ };
46
+ }, [open]);
47
+
48
+ const run = (fn?: () => void) => () => {
49
+ setOpen(false);
50
+ fn?.();
51
+ };
52
+
53
+ const items: { key: string; icon: ReactNode; label: string; fn?: () => void; muted?: boolean }[] = [
54
+ { key: "copy", icon: <Link2 size={18} />, label: "Copy link", fn: onCopyLink },
55
+ { key: "messages", icon: <MessageCircle size={18} />, label: "Send in Messages", fn: onMessages },
56
+ { key: "email", icon: <Mail size={18} />, label: "Email", fn: onEmail },
57
+ ].filter((i) => i.fn);
58
+
59
+ return (
60
+ <div ref={ref} style={{ position: "relative", display: "inline-flex" }}>
61
+ <TopBarIconButton compact={compact} aria-label={`Share ${noun}`} aria-expanded={open} onClick={() => setOpen((v) => !v)}>
62
+ {/* iOS-style upload/share glyph (exact paths from the handoff). */}
63
+ <svg width={18} height={18} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
64
+ <path d="M12 14.5V3.5" />
65
+ <path d="M8 7.2l4-3.7 4 3.7" />
66
+ <path d="M5.5 12.5v6a2 2 0 0 0 2 2h9a2 2 0 0 0 2-2v-6" />
67
+ </svg>
68
+ </TopBarIconButton>
69
+
70
+ {open && (
71
+ <>
72
+ <style>{
73
+ "@keyframes sklShareMenuPop{from{opacity:0;transform:translateY(-6px) scale(.97)}to{opacity:1;transform:none}}"
74
+ }</style>
75
+ <div role="menu" style={panelStyle}>
76
+ <div style={sectionLabelStyle}>Share {noun}</div>
77
+ {items.map((it) => (
78
+ <MenuItem key={it.key} icon={it.icon} label={it.label} onClick={run(it.fn)} />
79
+ ))}
80
+ {onMore && (
81
+ <>
82
+ <div style={dividerStyle} />
83
+ <MenuItem icon={<MoreHorizontal size={18} />} label="More options…" onClick={run(onMore)} muted />
84
+ </>
85
+ )}
86
+ </div>
87
+ </>
88
+ )}
89
+ </div>
90
+ );
91
+ }
92
+
93
+ function MenuItem({ icon, label, onClick, muted }: { icon: ReactNode; label: string; onClick: () => void; muted?: boolean }) {
94
+ const [hover, setHover] = useState(false);
95
+ return (
96
+ <button
97
+ type="button"
98
+ role="menuitem"
99
+ onClick={onClick}
100
+ onMouseEnter={() => setHover(true)}
101
+ onMouseLeave={() => setHover(false)}
102
+ style={{
103
+ display: "flex",
104
+ alignItems: "center",
105
+ gap: 11,
106
+ width: "100%",
107
+ padding: "9px 10px",
108
+ border: "none",
109
+ background: hover ? "var(--skl-color-surface-muted)" : "transparent",
110
+ borderRadius: 8,
111
+ font: "600 14px var(--skl-font-sans)",
112
+ color: muted ? "var(--skl-color-text-muted)" : "var(--skl-color-text)",
113
+ textAlign: "left",
114
+ cursor: "pointer",
115
+ }}
116
+ >
117
+ <span style={{ display: "inline-flex", color: muted ? "var(--skl-color-text-faint)" : "var(--skl-color-text-muted)" }}>{icon}</span>
118
+ {label}
119
+ </button>
120
+ );
121
+ }
122
+
123
+ const panelStyle: CSSProperties = {
124
+ position: "absolute",
125
+ top: "calc(100% + 8px)",
126
+ right: 0,
127
+ width: 226,
128
+ background: "var(--skl-color-surface)",
129
+ border: "1px solid var(--skl-color-border)",
130
+ borderRadius: "var(--skl-radius-panel)",
131
+ boxShadow: "var(--skl-shadow-menu)",
132
+ padding: 6,
133
+ zIndex: 70,
134
+ transformOrigin: "top right",
135
+ animation: "sklShareMenuPop .16s ease-out",
136
+ };
137
+
138
+ const sectionLabelStyle: CSSProperties = {
139
+ padding: "8px 10px 7px",
140
+ font: "700 10.5px var(--skl-font-sans)",
141
+ textTransform: "uppercase",
142
+ letterSpacing: "0.6px",
143
+ color: "var(--skl-color-text-faint)",
144
+ };
145
+
146
+ const dividerStyle: CSSProperties = { height: 1, background: "var(--skl-color-border)", margin: "6px 8px" };
package/src/TopBar.tsx ADDED
@@ -0,0 +1,105 @@
1
+ import { useState, type ButtonHTMLAttributes, type CSSProperties, type ReactNode } from "react";
2
+
3
+ import { AppLogo, type AppGlyph } from "./AppLogo.js";
4
+ import { ProfileMenu, type ProfileMenuProps } from "./ProfileMenu.js";
5
+
6
+ /**
7
+ * The shared SkipLeague top bar (design_handoff_top_bar, Option A — dark).
8
+ *
9
+ * Layout is identical across apps; only the logo glyph, wordmark, and the
10
+ * contextual action slot change:
11
+ * [AppLogo + wordmark] ........ [ ...actions ][ ProfileMenu ]
12
+ *
13
+ * - ProfileMenu is pinned to the far right on every page (the persistent slot).
14
+ * - `actions` is the contextual slot (Print / Share / …) — render zero or more
15
+ * {@link TopBarIconButton}s; they appear left of the ProfileMenu only when the
16
+ * current page offers them.
17
+ */
18
+ export function TopBar({
19
+ app,
20
+ appName,
21
+ actions,
22
+ compact = false,
23
+ ...profile
24
+ }: {
25
+ app: AppGlyph;
26
+ appName: string;
27
+ /** Contextual page actions (Print/Share buttons) rendered left of ProfileMenu. */
28
+ actions?: ReactNode;
29
+ /** Mobile sizing (48px bar) vs the default 54px. */
30
+ compact?: boolean;
31
+ } & Pick<
32
+ ProfileMenuProps,
33
+ "user" | "currentSlug" | "apps" | "enabledSlugs" | "accountUrl" | "onSignOut" | "renderLink" | "onSignIn"
34
+ >) {
35
+ const bar: CSSProperties = {
36
+ display: "flex",
37
+ alignItems: "center",
38
+ justifyContent: "space-between",
39
+ height: compact ? 48 : 54,
40
+ padding: compact ? "0 12px" : "0 14px",
41
+ background: "var(--skl-color-header-dark)",
42
+ };
43
+ return (
44
+ <div style={bar}>
45
+ <div style={{ display: "flex", alignItems: "center", gap: 10, minWidth: 0 }}>
46
+ <AppLogo app={app} size={compact ? 26 : 27} />
47
+ <span
48
+ style={{
49
+ fontFamily: "var(--skl-font-sans)",
50
+ fontWeight: 700,
51
+ fontSize: compact ? 14 : 15,
52
+ color: "var(--skl-color-on-dark)",
53
+ whiteSpace: "nowrap",
54
+ overflow: "hidden",
55
+ textOverflow: "ellipsis",
56
+ }}
57
+ >
58
+ {appName}
59
+ </span>
60
+ </div>
61
+ <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
62
+ {actions}
63
+ <ProfileMenu tone="dark" {...profile} />
64
+ </div>
65
+ </div>
66
+ );
67
+ }
68
+
69
+ /**
70
+ * A contextual top-bar action button (Print, Share, …) styled for the dark bar.
71
+ * Pass a 17–18px line icon (stroke 2, currentColor) as children.
72
+ */
73
+ export function TopBarIconButton({
74
+ compact = false,
75
+ style,
76
+ children,
77
+ ...props
78
+ }: ButtonHTMLAttributes<HTMLButtonElement> & { compact?: boolean }) {
79
+ const [hover, setHover] = useState(false);
80
+ const dim = compact ? 36 : 38;
81
+ return (
82
+ <button
83
+ type="button"
84
+ onMouseEnter={() => setHover(true)}
85
+ onMouseLeave={() => setHover(false)}
86
+ style={{
87
+ width: dim,
88
+ height: dim,
89
+ borderRadius: 9,
90
+ display: "inline-flex",
91
+ alignItems: "center",
92
+ justifyContent: "center",
93
+ cursor: "pointer",
94
+ background: hover ? "rgba(255,255,255,0.08)" : "transparent",
95
+ border: `1px solid ${hover ? "rgba(94,234,212,0.5)" : "rgba(255,255,255,0.16)"}`,
96
+ color: hover ? "var(--skl-color-brand-bright)" : "var(--skl-color-on-dark-muted)",
97
+ transition: "background .12s, border-color .12s, color .12s",
98
+ ...style,
99
+ }}
100
+ {...props}
101
+ >
102
+ {children}
103
+ </button>
104
+ );
105
+ }
package/src/apps.ts ADDED
@@ -0,0 +1,28 @@
1
+ /** A SkipLeague app the ProfileMenu can switch between. */
2
+ export interface AppLink {
3
+ /** Platform app slug, e.g. "skiplists". Matches the registry + introspect. */
4
+ slug: string;
5
+ /** Display name, e.g. "SkipLists". */
6
+ name: string;
7
+ /** Absolute URL to the app. */
8
+ url: string;
9
+ }
10
+
11
+ /**
12
+ * Canonical list of LIVE SkipLeague apps shown in every app's switcher.
13
+ * Add an app here (one place) when it goes live and every app's menu updates.
14
+ * What each user actually sees is this list filtered by their enabled apps —
15
+ * pass `enabledSlugs={user.app_slugs}` to ProfileMenu. SkipEvolve is omitted
16
+ * until it launches.
17
+ */
18
+ export const SKIPLEAGUE_APPS: AppLink[] = [
19
+ { slug: "skiplists", name: "SkipLists", url: "https://lists.skipleague.com" },
20
+ { slug: "skipracquetball", name: "SkipRacquetball", url: "https://racquetball.skipleague.com" },
21
+ { slug: "skiptrips", name: "SkipTrips", url: "https://trips.skipleague.com" },
22
+ { slug: "skipreading", name: "SkipReading", url: "https://reading.skipleague.com" },
23
+ { slug: "skipgifts", name: "SkipGifts", url: "https://gifts.skipleague.com" },
24
+ { slug: "skiptoday", name: "SkipToday", url: "https://today.skipleague.com" },
25
+ ];
26
+
27
+ /** Default target for the menu's "Manage account" link (the platform account page). */
28
+ export const SKIPLEAGUE_ACCOUNT_URL = "https://skipleague.com/account";
package/src/index.ts ADDED
@@ -0,0 +1,16 @@
1
+ export { ProfileMenu } from "./ProfileMenu.js";
2
+ export type { ProfileMenuProps, ProfileMenuUser, ProfileMenuLinkArgs } from "./ProfileMenu.js";
3
+ export { AppBadge } from "./AppBadge.js";
4
+ export { AppLogo } from "./AppLogo.js";
5
+ export type { AppGlyph } from "./AppLogo.js";
6
+ export { TopBar, TopBarIconButton } from "./TopBar.js";
7
+ export { BottomNav } from "./BottomNav.js";
8
+ export type { BottomNavProps, BottomNavTab, BottomNavAction } from "./BottomNav.js";
9
+ export { ShareMenu } from "./ShareMenu.js";
10
+ export { Button } from "./Button.js";
11
+ export type { ButtonProps } from "./Button.js";
12
+ export { Field, Input } from "./Field.js";
13
+ export { Select } from "./Select.js";
14
+ export { Card } from "./Card.js";
15
+ export { SKIPLEAGUE_APPS, SKIPLEAGUE_ACCOUNT_URL } from "./apps.js";
16
+ export type { AppLink } from "./apps.js";
package/src/styles.ts ADDED
@@ -0,0 +1,14 @@
1
+ import type { CSSProperties } from "react";
2
+
3
+ /** Base style shared by text inputs and selects. */
4
+ export const controlStyle: CSSProperties = {
5
+ width: "100%",
6
+ boxSizing: "border-box",
7
+ padding: "0.625rem 0.75rem",
8
+ fontFamily: "var(--skl-font-sans)",
9
+ fontSize: "var(--skl-text-sm)",
10
+ color: "var(--skl-color-text)",
11
+ background: "var(--skl-color-surface)",
12
+ border: "1px solid var(--skl-color-border)",
13
+ borderRadius: "var(--skl-radius-md)",
14
+ };
package/src/tokens.css ADDED
@@ -0,0 +1,43 @@
1
+ /**
2
+ * SkipUI design tokens — the SkipLeague visual language, extracted from the
3
+ * surfaces we like (home, account, SkipRacquetball). Import once at the app root:
4
+ * import "@skipleague/design/tokens.css";
5
+ * Components reference these variables, so the look is driven from here.
6
+ */
7
+ :root {
8
+ /* Typography */
9
+ --skl-font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
10
+ --skl-text-sm: 0.875rem;
11
+ --skl-text-xs: 0.75rem;
12
+ --skl-text-2xs: 0.7rem;
13
+
14
+ /* Brand */
15
+ --skl-color-brand: #0f766e; /* teal — primary actions, badges */
16
+ --skl-color-brand-bright: #5eead4; /* mint — on dark headers */
17
+
18
+ /* Dark header surface (the shared TopBar) + its on-dark foregrounds */
19
+ --skl-color-header-dark: #0e2e2a;
20
+ --skl-color-on-dark: #eafaf6; /* primary text on the dark header */
21
+ --skl-color-on-dark-muted: #9fe7d6; /* muted icons on the dark header */
22
+
23
+ /* Neutrals (slate) */
24
+ --skl-color-text: #0f172a;
25
+ --skl-color-text-muted: #64748b;
26
+ --skl-color-text-faint: #94a3b8;
27
+ --skl-color-border: #e2e8f0;
28
+ --skl-color-surface: #ffffff;
29
+ --skl-color-surface-muted: #f8fafc; /* disabled inputs, subtle fills */
30
+
31
+ /* "Current" highlight (selected app / current page) */
32
+ --skl-color-current-bg: #dcfce7;
33
+ --skl-color-current-text: #166534;
34
+
35
+ /* Radii */
36
+ --skl-radius-panel: 10px;
37
+ --skl-radius-control: 9px;
38
+ --skl-radius-md: 6px; /* buttons, inputs, selects */
39
+ --skl-radius-badge: 5px;
40
+
41
+ /* Elevation */
42
+ --skl-shadow-menu: 0 12px 32px rgba(2, 6, 23, 0.18);
43
+ }