@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.
- package/README.md +79 -0
- package/dist/AppBadge.d.ts +8 -0
- package/dist/AppBadge.js +21 -0
- package/dist/AppLogo.d.ts +13 -0
- package/dist/AppLogo.js +33 -0
- package/dist/BottomNav.d.ts +41 -0
- package/dist/BottomNav.js +145 -0
- package/dist/Button.d.ts +7 -0
- package/dist/Button.js +22 -0
- package/dist/Card.d.ts +10 -0
- package/dist/Card.js +13 -0
- package/dist/Field.d.ts +9 -0
- package/dist/Field.js +14 -0
- package/dist/ProfileMenu.d.ts +72 -0
- package/dist/ProfileMenu.js +106 -0
- package/dist/Select.d.ts +3 -0
- package/dist/Select.js +6 -0
- package/dist/ShareMenu.d.ts +16 -0
- package/dist/ShareMenu.js +83 -0
- package/dist/TopBar.d.ts +30 -0
- package/dist/TopBar.js +57 -0
- package/dist/apps.d.ts +19 -0
- package/dist/apps.js +17 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +11 -0
- package/dist/styles.d.ts +3 -0
- package/dist/styles.js +12 -0
- package/package.json +38 -0
- package/src/AppBadge.tsx +26 -0
- package/src/AppLogo.tsx +81 -0
- package/src/BottomNav.tsx +276 -0
- package/src/Button.tsx +28 -0
- package/src/Card.tsx +33 -0
- package/src/Field.tsx +40 -0
- package/src/ProfileMenu.tsx +275 -0
- package/src/Select.tsx +11 -0
- package/src/ShareMenu.tsx +146 -0
- package/src/TopBar.tsx +105 -0
- package/src/apps.ts +28 -0
- package/src/index.ts +16 -0
- package/src/styles.ts +14 -0
- package/src/tokens.css +43 -0
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import type { CSSProperties, ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The shared SkipLeague bottom navigation (design_handoff_bottom_nav).
|
|
5
|
+
*
|
|
6
|
+
* ONE layout for every app: a row of tabs split evenly around a floating
|
|
7
|
+
* center action ("+"). Only the tab labels/icons and the action's label change
|
|
8
|
+
* per app — spacing, sizing, the button, and its clipped glow are identical
|
|
9
|
+
* everywhere, so the bar looks the same across the family.
|
|
10
|
+
*
|
|
11
|
+
* [tab][tab] ... ( + ) ... [tab][tab]
|
|
12
|
+
* label
|
|
13
|
+
*
|
|
14
|
+
* - `tabs` holds 2–8 tabs. They split around the center: the first
|
|
15
|
+
* `ceil(n/2)` render left of the "+", the rest render right. (For an odd
|
|
16
|
+
* count the extra tab sits on the left; the "+" tracks the true center of
|
|
17
|
+
* its column, so it drifts slightly right of screen-center — even counts are
|
|
18
|
+
* the designed-for case.)
|
|
19
|
+
* - `action` is the floating center button and the word beneath it.
|
|
20
|
+
* - `groups` (optional, even counts only) adds a header band labelling the left
|
|
21
|
+
* pair/triple vs the right. Total bar height is unchanged — the tabs shift
|
|
22
|
+
* down to make room.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const ICON = 24; // tab icon box (24px line icons, stroke 2)
|
|
26
|
+
const LABEL_GAP = 10; // icon -> label vertical gap
|
|
27
|
+
const ACTION_SIZE = 53; // floating center button (square)
|
|
28
|
+
const ACTION_RADIUS = 17; // center button corner radius
|
|
29
|
+
const ACTION_OVERHANG = 10; // px the button rises above the bar's top edge
|
|
30
|
+
const PLUS = 26; // "+" glyph box inside the button
|
|
31
|
+
|
|
32
|
+
// Soft mint halo + a grounding shadow. Painted on a layer INSIDE the
|
|
33
|
+
// overflow:hidden bar so it is clipped at the top edge — no glow above the line.
|
|
34
|
+
const ACTION_GLOW = "0 0 22px 4px rgba(94,234,212,0.6), 0 10px 22px rgba(15,118,110,0.30)";
|
|
35
|
+
|
|
36
|
+
// Two vertical-spacing presets that yield the SAME total bar height.
|
|
37
|
+
const PLAIN = { padTop: 10, padBottom: 18, headerGap: 0 };
|
|
38
|
+
const GROUPED = { padTop: 5, padBottom: 6, headerGap: 5 };
|
|
39
|
+
|
|
40
|
+
export interface BottomNavTab {
|
|
41
|
+
label: string;
|
|
42
|
+
/** 24px line icon (stroke 2, currentColor) — inherits the cell's color. */
|
|
43
|
+
icon: ReactNode;
|
|
44
|
+
/** Current tab (accent color, bold). Derive from your router. */
|
|
45
|
+
active?: boolean;
|
|
46
|
+
/**
|
|
47
|
+
* "Tab Colors" option — a per-tab accent (any CSS color). When set and this
|
|
48
|
+
* tab is active, its icon/label AND the center button + glow use this color
|
|
49
|
+
* instead of the default brand. Omit on every tab to keep the standard
|
|
50
|
+
* single-color bar (the default). Provide a color per tab for an app whose
|
|
51
|
+
* tabs each have their own identity (e.g. SkipGifts).
|
|
52
|
+
*/
|
|
53
|
+
color?: string;
|
|
54
|
+
onClick?: () => void;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface BottomNavAction {
|
|
58
|
+
/** Short visual label under the "+" (e.g. "List", "Match"). */
|
|
59
|
+
label: string;
|
|
60
|
+
/**
|
|
61
|
+
* Accessible name for the action control, announced to screen-reader and
|
|
62
|
+
* voice-control users. Use a verb phrase ("New list", "Log match") so the
|
|
63
|
+
* button isn't read as a bare noun confusable with a tab. Falls back to
|
|
64
|
+
* `label`. The visual label always stays `label`; per WCAG 2.5.3, keep
|
|
65
|
+
* `label`'s text inside `ariaLabel` (e.g. label "List" → ariaLabel "New list").
|
|
66
|
+
*/
|
|
67
|
+
ariaLabel?: string;
|
|
68
|
+
/** The "+" glyph (white, ~26px, stroke 2.4). Inherits white from the button. */
|
|
69
|
+
icon: ReactNode;
|
|
70
|
+
onClick?: () => void;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface BottomNavProps {
|
|
74
|
+
/** 2–8 tabs, split evenly around the center action. */
|
|
75
|
+
tabs: BottomNavTab[];
|
|
76
|
+
/** The floating center "+" button and its label. */
|
|
77
|
+
action: BottomNavAction;
|
|
78
|
+
/** Optional grouped-header band; even tab counts only. */
|
|
79
|
+
groups?: [string, string];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const cell: CSSProperties = {
|
|
83
|
+
display: "flex",
|
|
84
|
+
flexDirection: "column",
|
|
85
|
+
alignItems: "center",
|
|
86
|
+
gap: LABEL_GAP,
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const labelBase: CSSProperties = {
|
|
90
|
+
fontFamily: "var(--skl-font-sans)",
|
|
91
|
+
fontSize: 13,
|
|
92
|
+
lineHeight: 1.15,
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const groupLabel: CSSProperties = {
|
|
96
|
+
...labelBase,
|
|
97
|
+
fontSize: 11,
|
|
98
|
+
fontWeight: 700,
|
|
99
|
+
letterSpacing: "0.1em",
|
|
100
|
+
textTransform: "uppercase",
|
|
101
|
+
color: "var(--skl-color-text-faint)",
|
|
102
|
+
textAlign: "center",
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
function Tab({ label, icon, active, color, onClick, lift }: BottomNavTab & { lift: number }) {
|
|
106
|
+
const accent = color ?? "var(--skl-color-brand)";
|
|
107
|
+
return (
|
|
108
|
+
<button
|
|
109
|
+
type="button"
|
|
110
|
+
onClick={onClick}
|
|
111
|
+
style={{
|
|
112
|
+
...cell,
|
|
113
|
+
background: "none",
|
|
114
|
+
border: "none",
|
|
115
|
+
padding: 0,
|
|
116
|
+
cursor: "pointer",
|
|
117
|
+
transform: `translateY(${lift}px)`,
|
|
118
|
+
// icon color (currentColor); the label sets its own color below
|
|
119
|
+
color: active ? accent : "var(--skl-color-text-faint)",
|
|
120
|
+
}}
|
|
121
|
+
>
|
|
122
|
+
<span style={{ height: ICON, display: "flex", alignItems: "flex-end" }}>{icon}</span>
|
|
123
|
+
<span
|
|
124
|
+
style={{
|
|
125
|
+
...labelBase,
|
|
126
|
+
fontWeight: active ? 700 : 600,
|
|
127
|
+
color: active ? accent : "var(--skl-color-text-muted)",
|
|
128
|
+
}}
|
|
129
|
+
>
|
|
130
|
+
{label}
|
|
131
|
+
</span>
|
|
132
|
+
</button>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function BottomNav({ tabs, action, groups }: BottomNavProps) {
|
|
137
|
+
const n = tabs.length;
|
|
138
|
+
if (n < 2 || n > 8) {
|
|
139
|
+
// Render what we were given, but flag the contract violation in dev.
|
|
140
|
+
console.warn(`BottomNav expects 2–8 tabs, received ${n}.`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Split around the center: extra tab (odd n) goes left.
|
|
144
|
+
const leftCount = Math.ceil(n / 2);
|
|
145
|
+
const left = tabs.slice(0, leftCount);
|
|
146
|
+
const right = tabs.slice(leftCount);
|
|
147
|
+
const columns = n + 1; // tabs + the center label column
|
|
148
|
+
|
|
149
|
+
// Grouped headers are an even-count feature (a header over each side).
|
|
150
|
+
const grouped = Array.isArray(groups) && groups.length === 2 && n % 2 === 0;
|
|
151
|
+
const sp = grouped ? GROUPED : PLAIN;
|
|
152
|
+
|
|
153
|
+
// In grouped mode the tab row sits lower; lift the center label back in line
|
|
154
|
+
// with the side tabs (right under the "+"), and nudge the tabs up for balance.
|
|
155
|
+
const centerLabelLift = grouped ? -13 : 0;
|
|
156
|
+
const tabLift = grouped ? -2 : 0;
|
|
157
|
+
|
|
158
|
+
// Horizontal center of the action column (== 50% for even counts).
|
|
159
|
+
const actionLeft = `${((leftCount + 0.5) / columns) * 100}%`;
|
|
160
|
+
const gridCols = `repeat(${columns}, 1fr)`;
|
|
161
|
+
|
|
162
|
+
// "Tab Colors": the center button + label + glow follow the active tab's color
|
|
163
|
+
// when one is set; otherwise everything stays the default brand teal + mint glow.
|
|
164
|
+
const activeTab = tabs.find((t) => t.active);
|
|
165
|
+
const accent = activeTab?.color ?? "var(--skl-color-brand)";
|
|
166
|
+
const glow = activeTab?.color
|
|
167
|
+
? `0 0 22px 4px color-mix(in srgb, ${activeTab.color} 55%, transparent), 0 10px 22px color-mix(in srgb, ${activeTab.color} 30%, transparent)`
|
|
168
|
+
: ACTION_GLOW;
|
|
169
|
+
|
|
170
|
+
return (
|
|
171
|
+
<nav style={{ position: "relative", flex: "none" }}>
|
|
172
|
+
{/* Bar — clips the glow at its top edge ("the line"). */}
|
|
173
|
+
<div
|
|
174
|
+
style={{
|
|
175
|
+
background: "var(--skl-color-surface)",
|
|
176
|
+
borderTop: "1px solid var(--skl-color-border)",
|
|
177
|
+
position: "relative",
|
|
178
|
+
overflow: "hidden",
|
|
179
|
+
// Keep the tabs clear of the iOS home indicator (additive to padBottom).
|
|
180
|
+
paddingBottom: "env(safe-area-inset-bottom, 0px)",
|
|
181
|
+
}}
|
|
182
|
+
>
|
|
183
|
+
{/* Glow layer (clipped to the bar; sits behind the grid). */}
|
|
184
|
+
<span
|
|
185
|
+
aria-hidden
|
|
186
|
+
style={{
|
|
187
|
+
position: "absolute",
|
|
188
|
+
top: -ACTION_OVERHANG,
|
|
189
|
+
left: actionLeft,
|
|
190
|
+
transform: "translateX(-50%)",
|
|
191
|
+
width: ACTION_SIZE,
|
|
192
|
+
height: ACTION_SIZE,
|
|
193
|
+
borderRadius: ACTION_RADIUS,
|
|
194
|
+
boxShadow: glow,
|
|
195
|
+
pointerEvents: "none",
|
|
196
|
+
}}
|
|
197
|
+
/>
|
|
198
|
+
|
|
199
|
+
{/* Optional group-header band (labels left vs right). */}
|
|
200
|
+
{grouped && (
|
|
201
|
+
<div style={{ display: "grid", gridTemplateColumns: gridCols, paddingTop: sp.padTop }}>
|
|
202
|
+
<div style={{ ...groupLabel, gridColumn: `1 / ${leftCount + 1}` }}>{groups![0]}</div>
|
|
203
|
+
<div style={{ ...groupLabel, gridColumn: `${leftCount + 2} / ${columns + 1}` }}>
|
|
204
|
+
{groups![1]}
|
|
205
|
+
</div>
|
|
206
|
+
</div>
|
|
207
|
+
)}
|
|
208
|
+
|
|
209
|
+
{/* Tab row. */}
|
|
210
|
+
<div
|
|
211
|
+
style={{
|
|
212
|
+
display: "grid",
|
|
213
|
+
gridTemplateColumns: gridCols,
|
|
214
|
+
paddingTop: grouped ? sp.headerGap : sp.padTop,
|
|
215
|
+
paddingBottom: sp.padBottom,
|
|
216
|
+
position: "relative",
|
|
217
|
+
}}
|
|
218
|
+
>
|
|
219
|
+
{left.map((t, i) => (
|
|
220
|
+
<Tab key={`l${i}`} {...t} lift={tabLift} />
|
|
221
|
+
))}
|
|
222
|
+
|
|
223
|
+
{/* Center column: the action's label. Pointer-clickable (tapping the
|
|
224
|
+
word fires the action too) but NOT a separate focusable control or
|
|
225
|
+
screen-reader announcement — the floating "+" below is the single
|
|
226
|
+
control for the action, so `aria-hidden` here avoids a duplicate tab
|
|
227
|
+
stop / double announcement. Keyboard & SR users get exactly one
|
|
228
|
+
control per action; touch users can still tap the word. */}
|
|
229
|
+
<div onClick={action.onClick} aria-hidden style={{ ...cell, cursor: "pointer" }}>
|
|
230
|
+
<div style={{ height: ICON }} />
|
|
231
|
+
<span
|
|
232
|
+
style={{
|
|
233
|
+
...labelBase,
|
|
234
|
+
fontWeight: 700,
|
|
235
|
+
color: accent,
|
|
236
|
+
transform: `translateY(${centerLabelLift}px)`,
|
|
237
|
+
}}
|
|
238
|
+
>
|
|
239
|
+
{action.label}
|
|
240
|
+
</span>
|
|
241
|
+
</div>
|
|
242
|
+
|
|
243
|
+
{right.map((t, i) => (
|
|
244
|
+
<Tab key={`r${i}`} {...t} lift={tabLift} />
|
|
245
|
+
))}
|
|
246
|
+
</div>
|
|
247
|
+
</div>
|
|
248
|
+
|
|
249
|
+
{/* Floating center button — unclipped, rises ACTION_OVERHANG above the bar. */}
|
|
250
|
+
<button
|
|
251
|
+
type="button"
|
|
252
|
+
onClick={action.onClick}
|
|
253
|
+
aria-label={action.ariaLabel ?? action.label}
|
|
254
|
+
style={{
|
|
255
|
+
position: "absolute",
|
|
256
|
+
top: -ACTION_OVERHANG,
|
|
257
|
+
left: actionLeft,
|
|
258
|
+
transform: "translateX(-50%)",
|
|
259
|
+
width: ACTION_SIZE,
|
|
260
|
+
height: ACTION_SIZE,
|
|
261
|
+
borderRadius: ACTION_RADIUS,
|
|
262
|
+
background: accent,
|
|
263
|
+
color: "#fff",
|
|
264
|
+
border: "none",
|
|
265
|
+
display: "flex",
|
|
266
|
+
alignItems: "center",
|
|
267
|
+
justifyContent: "center",
|
|
268
|
+
zIndex: 3,
|
|
269
|
+
cursor: "pointer",
|
|
270
|
+
}}
|
|
271
|
+
>
|
|
272
|
+
<span style={{ display: "flex", width: PLUS, height: PLUS }}>{action.icon}</span>
|
|
273
|
+
</button>
|
|
274
|
+
</nav>
|
|
275
|
+
);
|
|
276
|
+
}
|
package/src/Button.tsx
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { ButtonHTMLAttributes, CSSProperties } from "react";
|
|
2
|
+
|
|
3
|
+
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
4
|
+
/** "primary" = brand-green fill (default); "secondary" = white with a border. */
|
|
5
|
+
variant?: "primary" | "secondary";
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** The standard SkipLeague button. Token-driven; matches the apps' primary action. */
|
|
9
|
+
export function Button({ variant = "primary", style, disabled, ...props }: ButtonProps) {
|
|
10
|
+
const base: CSSProperties = {
|
|
11
|
+
display: "inline-flex",
|
|
12
|
+
alignItems: "center",
|
|
13
|
+
justifyContent: "center",
|
|
14
|
+
gap: "0.5rem",
|
|
15
|
+
fontFamily: "var(--skl-font-sans)",
|
|
16
|
+
fontSize: "var(--skl-text-sm)",
|
|
17
|
+
fontWeight: 600,
|
|
18
|
+
padding: "0.625rem 1.25rem",
|
|
19
|
+
borderRadius: "var(--skl-radius-md)",
|
|
20
|
+
cursor: disabled ? "default" : "pointer",
|
|
21
|
+
opacity: disabled ? 0.6 : 1,
|
|
22
|
+
border: "1px solid transparent",
|
|
23
|
+
...(variant === "primary"
|
|
24
|
+
? { background: "var(--skl-color-brand)", color: "#fff" }
|
|
25
|
+
: { background: "var(--skl-color-surface)", color: "var(--skl-color-text)", borderColor: "var(--skl-color-border)" }),
|
|
26
|
+
};
|
|
27
|
+
return <button {...props} disabled={disabled} style={{ ...base, ...style }} />;
|
|
28
|
+
}
|
package/src/Card.tsx
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { CSSProperties, ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A section card: an optional heading above a white, bordered, rounded container —
|
|
5
|
+
* the standard grouping used on the account/settings pages.
|
|
6
|
+
*/
|
|
7
|
+
export function Card({
|
|
8
|
+
title,
|
|
9
|
+
children,
|
|
10
|
+
style,
|
|
11
|
+
}: {
|
|
12
|
+
title?: string;
|
|
13
|
+
children: ReactNode;
|
|
14
|
+
style?: CSSProperties;
|
|
15
|
+
}) {
|
|
16
|
+
return (
|
|
17
|
+
<section style={{ fontFamily: "var(--skl-font-sans)", ...style }}>
|
|
18
|
+
{title && (
|
|
19
|
+
<h3 style={{ margin: "0 0 0.75rem", fontSize: "1rem", fontWeight: 600, color: "var(--skl-color-text)" }}>{title}</h3>
|
|
20
|
+
)}
|
|
21
|
+
<div
|
|
22
|
+
style={{
|
|
23
|
+
background: "var(--skl-color-surface)",
|
|
24
|
+
border: "1px solid var(--skl-color-border)",
|
|
25
|
+
borderRadius: "var(--skl-radius-panel)",
|
|
26
|
+
padding: "1rem",
|
|
27
|
+
}}
|
|
28
|
+
>
|
|
29
|
+
{children}
|
|
30
|
+
</div>
|
|
31
|
+
</section>
|
|
32
|
+
);
|
|
33
|
+
}
|
package/src/Field.tsx
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { InputHTMLAttributes, ReactNode } from "react";
|
|
2
|
+
import { controlStyle } from "./styles.js";
|
|
3
|
+
|
|
4
|
+
/** A labeled form row: a label, the control (children), and an optional hint. */
|
|
5
|
+
export function Field({
|
|
6
|
+
label,
|
|
7
|
+
hint,
|
|
8
|
+
children,
|
|
9
|
+
}: {
|
|
10
|
+
label: string;
|
|
11
|
+
hint?: ReactNode;
|
|
12
|
+
children: ReactNode;
|
|
13
|
+
}) {
|
|
14
|
+
return (
|
|
15
|
+
<div style={{ fontFamily: "var(--skl-font-sans)" }}>
|
|
16
|
+
<label style={{ display: "block", fontSize: "var(--skl-text-sm)", fontWeight: 500, marginBottom: 4, color: "var(--skl-color-text)" }}>
|
|
17
|
+
{label}
|
|
18
|
+
</label>
|
|
19
|
+
{children}
|
|
20
|
+
{hint && (
|
|
21
|
+
<p style={{ margin: "0.25rem 0 0", fontSize: "var(--skl-text-xs)", color: "var(--skl-color-text-muted)" }}>{hint}</p>
|
|
22
|
+
)}
|
|
23
|
+
</div>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A styled text input. */
|
|
28
|
+
export function Input({ style, disabled, ...props }: InputHTMLAttributes<HTMLInputElement>) {
|
|
29
|
+
return (
|
|
30
|
+
<input
|
|
31
|
+
{...props}
|
|
32
|
+
disabled={disabled}
|
|
33
|
+
style={{
|
|
34
|
+
...controlStyle,
|
|
35
|
+
...(disabled ? { background: "var(--skl-color-surface-muted)", color: "var(--skl-color-text-muted)" } : {}),
|
|
36
|
+
...style,
|
|
37
|
+
}}
|
|
38
|
+
/>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
|
2
|
+
import { CircleUser, LogIn, LogOut, Settings } from "lucide-react";
|
|
3
|
+
import { AppBadge } from "./AppBadge.js";
|
|
4
|
+
import { SKIPLEAGUE_ACCOUNT_URL, SKIPLEAGUE_APPS, type AppLink } from "./apps.js";
|
|
5
|
+
|
|
6
|
+
export interface ProfileMenuUser {
|
|
7
|
+
displayName?: string | null;
|
|
8
|
+
email?: string | null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Args passed to a custom `renderLink` for internal (in-app) menu links. */
|
|
12
|
+
export interface ProfileMenuLinkArgs {
|
|
13
|
+
href: string;
|
|
14
|
+
role?: string;
|
|
15
|
+
style?: CSSProperties;
|
|
16
|
+
children: ReactNode;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ProfileMenuProps {
|
|
20
|
+
/** The signed-in user (name + email shown at the top of the menu). */
|
|
21
|
+
user?: ProfileMenuUser;
|
|
22
|
+
/**
|
|
23
|
+
* Slug of the app you're currently in (e.g. "skipracquetball"). That app's row
|
|
24
|
+
* is highlighted light-green and non-clickable. Omit on the platform apex.
|
|
25
|
+
*/
|
|
26
|
+
currentSlug?: string;
|
|
27
|
+
/** Apps to list in the switcher. Defaults to the live SkipLeague apps. */
|
|
28
|
+
apps?: AppLink[];
|
|
29
|
+
/**
|
|
30
|
+
* Slugs of the apps THIS user has enabled (e.g. the platform's `app_slugs`).
|
|
31
|
+
* When provided, the switcher shows only those apps (the current app is always
|
|
32
|
+
* kept), so every app shows the same "your apps" set without each one
|
|
33
|
+
* re-implementing the filter. Omit to list every app in `apps`.
|
|
34
|
+
*/
|
|
35
|
+
enabledSlugs?: string[];
|
|
36
|
+
/** Target of the "Manage account" link. Defaults to the platform account page. */
|
|
37
|
+
accountUrl?: string;
|
|
38
|
+
/** Called when the user clicks "Sign out". */
|
|
39
|
+
onSignOut: () => void;
|
|
40
|
+
/**
|
|
41
|
+
* Header tone the trigger button sits on — "dark" (default) for the apex/dark
|
|
42
|
+
* headers, "light" for white headers. Only affects the button, not the menu.
|
|
43
|
+
*/
|
|
44
|
+
tone?: "light" | "dark";
|
|
45
|
+
/**
|
|
46
|
+
* Render the internal "Manage account" link via your own router (e.g. React
|
|
47
|
+
* Router's `<Link>`) instead of a full-page `<a href>`. App-switcher links to
|
|
48
|
+
* OTHER SkipLeague apps are always plain `<a>` (cross-app navigation).
|
|
49
|
+
* Example: `renderLink={({ href, ...p }) => <Link to={href} {...p} />}`.
|
|
50
|
+
*/
|
|
51
|
+
renderLink?: (args: ProfileMenuLinkArgs) => ReactNode;
|
|
52
|
+
/**
|
|
53
|
+
* Mark "Manage account" as the current page — highlighted light-green and
|
|
54
|
+
* non-clickable (e.g. the platform is already on /account).
|
|
55
|
+
*/
|
|
56
|
+
accountIsCurrent?: boolean;
|
|
57
|
+
/**
|
|
58
|
+
* Signed-out support (platform apex): when there's no `user` AND this is
|
|
59
|
+
* provided, the menu shows a single "Sign in" action instead of the
|
|
60
|
+
* name / Manage-account / Sign-out items. Omit it to keep the always-signed-in
|
|
61
|
+
* behavior product apps rely on.
|
|
62
|
+
*/
|
|
63
|
+
onSignIn?: () => void;
|
|
64
|
+
/** Label for the signed-out action (default "Sign in"). */
|
|
65
|
+
signInLabel?: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The canonical SkipLeague account control: a boxed user-icon button that opens
|
|
70
|
+
* a dropdown with the user's name/email, an inline **app switcher** (one click to
|
|
71
|
+
* jump to another SkipLeague app; the current app is highlighted and not
|
|
72
|
+
* clickable), **Manage account**, and **Sign out**. Also covers the platform
|
|
73
|
+
* apex via `renderLink` (SPA nav), `accountIsCurrent`, and `onSignIn`
|
|
74
|
+
* (signed-out state).
|
|
75
|
+
*
|
|
76
|
+
* Requires `@skipleague/design/tokens.css` to be imported once at the app root.
|
|
77
|
+
*/
|
|
78
|
+
export function ProfileMenu({
|
|
79
|
+
user,
|
|
80
|
+
currentSlug,
|
|
81
|
+
apps = SKIPLEAGUE_APPS,
|
|
82
|
+
enabledSlugs,
|
|
83
|
+
accountUrl = SKIPLEAGUE_ACCOUNT_URL,
|
|
84
|
+
onSignOut,
|
|
85
|
+
tone = "dark",
|
|
86
|
+
renderLink,
|
|
87
|
+
accountIsCurrent = false,
|
|
88
|
+
onSignIn,
|
|
89
|
+
signInLabel = "Sign in",
|
|
90
|
+
}: ProfileMenuProps) {
|
|
91
|
+
const [open, setOpen] = useState(false);
|
|
92
|
+
const [hover, setHover] = useState(false);
|
|
93
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
94
|
+
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
if (!open) return;
|
|
97
|
+
const onDocClick = (e: MouseEvent) => {
|
|
98
|
+
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
|
99
|
+
};
|
|
100
|
+
const onKey = (e: KeyboardEvent) => {
|
|
101
|
+
if (e.key === "Escape") setOpen(false);
|
|
102
|
+
};
|
|
103
|
+
document.addEventListener("mousedown", onDocClick);
|
|
104
|
+
document.addEventListener("keydown", onKey);
|
|
105
|
+
return () => {
|
|
106
|
+
document.removeEventListener("mousedown", onDocClick);
|
|
107
|
+
document.removeEventListener("keydown", onKey);
|
|
108
|
+
};
|
|
109
|
+
}, [open]);
|
|
110
|
+
|
|
111
|
+
// Show only the apps this user has enabled (always keeping the current app),
|
|
112
|
+
// when the caller passes the user's enabled slugs. Otherwise list every app.
|
|
113
|
+
const visibleApps = enabledSlugs
|
|
114
|
+
? apps.filter((a) => a.slug === currentSlug || enabledSlugs.includes(a.slug))
|
|
115
|
+
: apps;
|
|
116
|
+
|
|
117
|
+
const signedIn = !!(user?.displayName || user?.email);
|
|
118
|
+
// Signed-out menu only when a sign-in handler is supplied — otherwise keep the
|
|
119
|
+
// existing always-signed-in behavior product apps depend on.
|
|
120
|
+
const showSignedOut = !signedIn && !!onSignIn;
|
|
121
|
+
|
|
122
|
+
const label = signedIn ? user!.displayName || user!.email || "Account" : "Account";
|
|
123
|
+
const brand = tone === "light" ? "var(--skl-color-brand)" : "var(--skl-color-brand-bright)";
|
|
124
|
+
const idleIcon = tone === "light" ? "#334155" : "#e2e8f0";
|
|
125
|
+
const idleBorder = tone === "light" ? "var(--skl-color-border)" : "rgba(255,255,255,0.18)";
|
|
126
|
+
const boxBg = tone === "light" ? "#ffffff" : "rgba(255,255,255,0.06)";
|
|
127
|
+
const active = open || hover;
|
|
128
|
+
|
|
129
|
+
const accountLabel = (
|
|
130
|
+
<>
|
|
131
|
+
<Settings size={15} /> Manage account
|
|
132
|
+
</>
|
|
133
|
+
);
|
|
134
|
+
const accountItem = accountIsCurrent ? (
|
|
135
|
+
<div aria-current="page" style={{ ...itemStyle, fontWeight: 600, background: "var(--skl-color-current-bg)", color: "var(--skl-color-current-text)", cursor: "default" }}>
|
|
136
|
+
{accountLabel}
|
|
137
|
+
</div>
|
|
138
|
+
) : renderLink ? (
|
|
139
|
+
renderLink({ href: accountUrl, role: "menuitem", style: itemStyle, children: accountLabel })
|
|
140
|
+
) : (
|
|
141
|
+
<a href={accountUrl} role="menuitem" style={itemStyle}>
|
|
142
|
+
{accountLabel}
|
|
143
|
+
</a>
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
return (
|
|
147
|
+
<div ref={ref} style={{ position: "relative", fontFamily: "var(--skl-font-sans)" }}>
|
|
148
|
+
<button
|
|
149
|
+
onClick={() => setOpen((o) => !o)}
|
|
150
|
+
onMouseEnter={() => setHover(true)}
|
|
151
|
+
onMouseLeave={() => setHover(false)}
|
|
152
|
+
aria-haspopup="menu"
|
|
153
|
+
aria-expanded={open}
|
|
154
|
+
aria-label="Account"
|
|
155
|
+
title={label}
|
|
156
|
+
style={{
|
|
157
|
+
display: "inline-flex",
|
|
158
|
+
alignItems: "center",
|
|
159
|
+
justifyContent: "center",
|
|
160
|
+
width: 38,
|
|
161
|
+
height: 38,
|
|
162
|
+
borderRadius: "var(--skl-radius-control)",
|
|
163
|
+
cursor: "pointer",
|
|
164
|
+
border: `1px solid ${active ? brand : idleBorder}`,
|
|
165
|
+
background: boxBg,
|
|
166
|
+
color: active ? brand : idleIcon,
|
|
167
|
+
transition: "border-color 0.15s, color 0.15s",
|
|
168
|
+
}}
|
|
169
|
+
>
|
|
170
|
+
<CircleUser size={22} />
|
|
171
|
+
</button>
|
|
172
|
+
|
|
173
|
+
{open && (
|
|
174
|
+
<div role="menu" style={menuStyle}>
|
|
175
|
+
{showSignedOut ? (
|
|
176
|
+
<>
|
|
177
|
+
<div style={{ padding: "0.5rem 0.75rem", borderBottom: "1px solid var(--skl-color-border)" }}>
|
|
178
|
+
<div style={{ fontWeight: 600, fontSize: "var(--skl-text-sm)", color: "var(--skl-color-text)" }}>Not signed in</div>
|
|
179
|
+
</div>
|
|
180
|
+
<button
|
|
181
|
+
role="menuitem"
|
|
182
|
+
onClick={onSignIn}
|
|
183
|
+
style={{ ...itemStyle, width: "100%", textAlign: "left", background: "none", border: "none", cursor: "pointer" }}
|
|
184
|
+
>
|
|
185
|
+
<LogIn size={15} /> {signInLabel}
|
|
186
|
+
</button>
|
|
187
|
+
</>
|
|
188
|
+
) : (
|
|
189
|
+
<>
|
|
190
|
+
<div style={{ padding: "0.5rem 0.75rem", borderBottom: "1px solid var(--skl-color-border)" }}>
|
|
191
|
+
<div style={{ fontWeight: 600, fontSize: "var(--skl-text-sm)", color: "var(--skl-color-text)" }}>{label}</div>
|
|
192
|
+
{user?.email && <div style={{ fontSize: "var(--skl-text-xs)", color: "var(--skl-color-text-muted)" }}>{user.email}</div>}
|
|
193
|
+
</div>
|
|
194
|
+
|
|
195
|
+
{/* Switcher only when there are apps to switch to — pass apps={[]} to
|
|
196
|
+
hide it entirely (e.g. dev mode, or a single-app deployment). */}
|
|
197
|
+
{visibleApps.length > 0 && (
|
|
198
|
+
<>
|
|
199
|
+
<div style={switchHeading}>Switch app</div>
|
|
200
|
+
{visibleApps.map((a) => {
|
|
201
|
+
const isCurrent = a.slug === currentSlug;
|
|
202
|
+
const inner = (
|
|
203
|
+
<span style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
|
204
|
+
<AppBadge name={a.name} />
|
|
205
|
+
{a.name}
|
|
206
|
+
</span>
|
|
207
|
+
);
|
|
208
|
+
// The app you're in: light-green highlight, not clickable.
|
|
209
|
+
return isCurrent ? (
|
|
210
|
+
<div key={a.slug} aria-current="page" style={{ ...itemStyle, fontWeight: 600, background: "var(--skl-color-current-bg)", color: "var(--skl-color-current-text)", cursor: "default" }}>
|
|
211
|
+
{inner}
|
|
212
|
+
</div>
|
|
213
|
+
) : (
|
|
214
|
+
<a key={a.slug} href={a.url} role="menuitem" style={itemStyle}>
|
|
215
|
+
{inner}
|
|
216
|
+
</a>
|
|
217
|
+
);
|
|
218
|
+
})}
|
|
219
|
+
</>
|
|
220
|
+
)}
|
|
221
|
+
|
|
222
|
+
<div style={divider} />
|
|
223
|
+
{accountItem}
|
|
224
|
+
<div style={divider} />
|
|
225
|
+
<button
|
|
226
|
+
role="menuitem"
|
|
227
|
+
onClick={onSignOut}
|
|
228
|
+
style={{ ...itemStyle, width: "100%", textAlign: "left", background: "none", border: "none", cursor: "pointer" }}
|
|
229
|
+
>
|
|
230
|
+
<LogOut size={15} /> Sign out
|
|
231
|
+
</button>
|
|
232
|
+
</>
|
|
233
|
+
)}
|
|
234
|
+
</div>
|
|
235
|
+
)}
|
|
236
|
+
</div>
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const menuStyle: CSSProperties = {
|
|
241
|
+
position: "absolute",
|
|
242
|
+
right: 0,
|
|
243
|
+
top: "calc(100% + 8px)",
|
|
244
|
+
minWidth: 200,
|
|
245
|
+
background: "var(--skl-color-surface)",
|
|
246
|
+
border: "1px solid var(--skl-color-border)",
|
|
247
|
+
borderRadius: "var(--skl-radius-panel)",
|
|
248
|
+
boxShadow: "var(--skl-shadow-menu)",
|
|
249
|
+
overflow: "hidden",
|
|
250
|
+
zIndex: 50,
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
const itemStyle: CSSProperties = {
|
|
254
|
+
display: "flex",
|
|
255
|
+
alignItems: "center",
|
|
256
|
+
gap: "0.5rem",
|
|
257
|
+
padding: "0.55rem 0.75rem",
|
|
258
|
+
fontSize: "var(--skl-text-sm)",
|
|
259
|
+
color: "var(--skl-color-text)",
|
|
260
|
+
textDecoration: "none",
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
const switchHeading: CSSProperties = {
|
|
264
|
+
padding: "0.5rem 0.75rem 0.25rem",
|
|
265
|
+
fontSize: "var(--skl-text-2xs)",
|
|
266
|
+
fontWeight: 600,
|
|
267
|
+
color: "var(--skl-color-text-faint)",
|
|
268
|
+
textTransform: "uppercase",
|
|
269
|
+
letterSpacing: 0.5,
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
const divider: CSSProperties = {
|
|
273
|
+
borderTop: "1px solid var(--skl-color-border)",
|
|
274
|
+
margin: "0.25rem 0",
|
|
275
|
+
};
|
package/src/Select.tsx
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { SelectHTMLAttributes } from "react";
|
|
2
|
+
import { controlStyle } from "./styles.js";
|
|
3
|
+
|
|
4
|
+
/** A styled select. Pass `<option>`s as children. */
|
|
5
|
+
export function Select({ style, children, ...props }: SelectHTMLAttributes<HTMLSelectElement>) {
|
|
6
|
+
return (
|
|
7
|
+
<select {...props} style={{ ...controlStyle, ...style }}>
|
|
8
|
+
{children}
|
|
9
|
+
</select>
|
|
10
|
+
);
|
|
11
|
+
}
|