@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
package/README.md
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# @skipleague/design (SkipUI)
|
|
2
|
+
|
|
3
|
+
The shared SkipLeague design system (roadmap #17 / #60) — tokens + React components
|
|
4
|
+
so every SkipLeague app uses one set of primitives instead of per-app copies.
|
|
5
|
+
|
|
6
|
+
## Installing in an app
|
|
7
|
+
|
|
8
|
+
This is a **public repo installed directly from git** — no registry, no auth token.
|
|
9
|
+
Add it to the app's `package.json` pinned to a version tag:
|
|
10
|
+
|
|
11
|
+
```jsonc
|
|
12
|
+
// package.json
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@skipleague/design": "github:SkipLeague/design#v0.3.1"
|
|
15
|
+
}
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
`npm install` clones the repo at that tag and runs its `prepare` script (builds
|
|
19
|
+
`dist/`), so the package is ready to import — in local dev and in CI, with no
|
|
20
|
+
`.npmrc` and no token. To pull a newer version later, bump the `#vX.Y.Z` tag.
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
import "@skipleague/design/tokens.css";
|
|
24
|
+
import { ProfileMenu, TopBar, AppLogo, ShareMenu } from "@skipleague/design";
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Usage
|
|
28
|
+
|
|
29
|
+
Import the tokens once at the app root, then use components anywhere:
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
import "@skipleague/design/tokens.css";
|
|
33
|
+
import { ProfileMenu } from "@skipleague/design";
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
```tsx
|
|
37
|
+
<ProfileMenu
|
|
38
|
+
user={{ displayName: user.display_name, email: user.email }}
|
|
39
|
+
currentSlug="skipracquetball" // omit on the platform apex
|
|
40
|
+
accountUrl="https://skipleague.com/account"
|
|
41
|
+
onSignOut={logout}
|
|
42
|
+
tone="light" // "dark" for dark headers (default)
|
|
43
|
+
/>
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
The app switcher lists the live SkipLeague apps (`SKIPLEAGUE_APPS`); the current
|
|
47
|
+
app is highlighted light-green and non-clickable. To add an app (e.g. when
|
|
48
|
+
SkipToday/SkipEvolve launch), update `src/apps.ts` — every app's menu follows.
|
|
49
|
+
|
|
50
|
+
## Exports
|
|
51
|
+
|
|
52
|
+
- `ProfileMenu`, `AppBadge`, `AppLogo`, `TopBar`, `TopBarIconButton`, `ShareMenu`
|
|
53
|
+
- `Button`, `Card`, `Field`, `Input`, `Select`
|
|
54
|
+
- `SKIPLEAGUE_APPS`, `SKIPLEAGUE_ACCOUNT_URL`, type `AppLink`
|
|
55
|
+
- `@skipleague/design/tokens.css` — the design tokens (CSS variables)
|
|
56
|
+
|
|
57
|
+
## Build
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
npm install
|
|
61
|
+
npm run build # tsc → dist/
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Releasing a new version
|
|
65
|
+
|
|
66
|
+
There's no registry to publish to — consumers install from git by tag. To cut a
|
|
67
|
+
release, bump the version and push the tag, then point apps at the new tag:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
npm version patch # bump version + create the vX.Y.Z tag
|
|
71
|
+
git push --follow-tags # publish the tag for consumers to pin
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
`prepare` builds `dist/` automatically on every install, so `dist/` is not committed.
|
|
75
|
+
|
|
76
|
+
## Roadmap
|
|
77
|
+
|
|
78
|
+
Adopt `TopBar`/`AppLogo` + the rest across the apps (replacing per-app copies),
|
|
79
|
+
then keep extending the component set. See SkipPlatform roadmap #60.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The square letter-logo for a SkipLeague app — the first letter of the name
|
|
3
|
+
* with the "Skip" prefix dropped (e.g. "SkipLists" → "L"). Brand-green.
|
|
4
|
+
*/
|
|
5
|
+
export declare function AppBadge({ name, size }: {
|
|
6
|
+
name: string;
|
|
7
|
+
size?: number;
|
|
8
|
+
}): import("react").JSX.Element;
|
package/dist/AppBadge.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* The square letter-logo for a SkipLeague app — the first letter of the name
|
|
4
|
+
* with the "Skip" prefix dropped (e.g. "SkipLists" → "L"). Brand-green.
|
|
5
|
+
*/
|
|
6
|
+
export function AppBadge({ name, size = 20 }) {
|
|
7
|
+
const style = {
|
|
8
|
+
display: "inline-flex",
|
|
9
|
+
alignItems: "center",
|
|
10
|
+
justifyContent: "center",
|
|
11
|
+
width: size,
|
|
12
|
+
height: size,
|
|
13
|
+
borderRadius: "var(--skl-radius-badge)",
|
|
14
|
+
background: "var(--skl-color-brand)",
|
|
15
|
+
color: "#fff",
|
|
16
|
+
fontSize: "var(--skl-text-2xs)",
|
|
17
|
+
fontWeight: 700,
|
|
18
|
+
flexShrink: 0,
|
|
19
|
+
};
|
|
20
|
+
return (_jsx("span", { style: style, "aria-hidden": true, children: name.replace(/^Skip/, "").charAt(0) }));
|
|
21
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** A SkipLeague app's logo glyph (monoline, drawn on the brand tile). */
|
|
2
|
+
export type AppGlyph = "lists" | "racquetball" | "trips";
|
|
3
|
+
/**
|
|
4
|
+
* The per-app logo mark — a brand-green rounded tile holding a white monoline
|
|
5
|
+
* glyph. Replaces the single-letter {@link AppBadge}; gives each SkipLeague app a
|
|
6
|
+
* distinct mark for the top bar, app switcher, and home screens.
|
|
7
|
+
*/
|
|
8
|
+
export declare function AppLogo({ app, size, bg, glyph, }: {
|
|
9
|
+
app: AppGlyph;
|
|
10
|
+
size?: number;
|
|
11
|
+
bg?: string;
|
|
12
|
+
glyph?: string;
|
|
13
|
+
}): import("react").JSX.Element;
|
package/dist/AppLogo.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// Exact 24×24 glyph paths (design_handoff_top_bar). `color` only matters for the
|
|
3
|
+
// racquetball ball, which is filled rather than stroked.
|
|
4
|
+
function glyphPaths(app, color) {
|
|
5
|
+
switch (app) {
|
|
6
|
+
case "lists":
|
|
7
|
+
return (_jsxs(_Fragment, { children: [_jsx("path", { d: "M3.5 7l1.8 1.8L8.5 5.6" }), _jsx("path", { d: "M12.5 7H20.5" }), _jsx("path", { d: "M3.5 16l1.8 1.8L8.5 14.6" }), _jsx("path", { d: "M12.5 16.5H20.5" })] }));
|
|
8
|
+
case "racquetball":
|
|
9
|
+
return (_jsxs(_Fragment, { children: [_jsx("ellipse", { cx: "10", cy: "9", rx: "5.4", ry: "6.6", transform: "rotate(-34 10 9)" }), _jsx("path", { d: "M6.6 14.2L3.4 19.4" }), _jsx("circle", { cx: "18", cy: "17.4", r: "2.3", fill: color, stroke: "none" })] }));
|
|
10
|
+
case "trips":
|
|
11
|
+
return (_jsxs(_Fragment, { children: [_jsx("path", { d: "M12 21.5s6.6-5.8 6.6-10.5a6.6 6.6 0 1 0-13.2 0c0 4.7 6.6 10.5 6.6 10.5z" }), _jsx("circle", { cx: "12", cy: "10.8", r: "2.4" })] }));
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* The per-app logo mark — a brand-green rounded tile holding a white monoline
|
|
16
|
+
* glyph. Replaces the single-letter {@link AppBadge}; gives each SkipLeague app a
|
|
17
|
+
* distinct mark for the top bar, app switcher, and home screens.
|
|
18
|
+
*/
|
|
19
|
+
export function AppLogo({ app, size = 28, bg = "var(--skl-color-brand)", glyph = "#ffffff", }) {
|
|
20
|
+
const inner = Math.round(size * 0.84);
|
|
21
|
+
const style = {
|
|
22
|
+
display: "inline-flex",
|
|
23
|
+
alignItems: "center",
|
|
24
|
+
justifyContent: "center",
|
|
25
|
+
width: size,
|
|
26
|
+
height: size,
|
|
27
|
+
borderRadius: Math.round(size * 0.32),
|
|
28
|
+
background: bg,
|
|
29
|
+
boxShadow: "0 1px 2px rgba(2,6,23,0.18)",
|
|
30
|
+
flexShrink: 0,
|
|
31
|
+
};
|
|
32
|
+
return (_jsx("span", { style: style, "aria-hidden": true, children: _jsx("svg", { width: inner, height: inner, viewBox: "0 0 24 24", fill: "none", stroke: glyph, strokeWidth: 2.2, strokeLinecap: "round", strokeLinejoin: "round", children: glyphPaths(app, glyph) }) }));
|
|
33
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
export interface BottomNavTab {
|
|
3
|
+
label: string;
|
|
4
|
+
/** 24px line icon (stroke 2, currentColor) — inherits the cell's color. */
|
|
5
|
+
icon: ReactNode;
|
|
6
|
+
/** Current tab (accent color, bold). Derive from your router. */
|
|
7
|
+
active?: boolean;
|
|
8
|
+
/**
|
|
9
|
+
* "Tab Colors" option — a per-tab accent (any CSS color). When set and this
|
|
10
|
+
* tab is active, its icon/label AND the center button + glow use this color
|
|
11
|
+
* instead of the default brand. Omit on every tab to keep the standard
|
|
12
|
+
* single-color bar (the default). Provide a color per tab for an app whose
|
|
13
|
+
* tabs each have their own identity (e.g. SkipGifts).
|
|
14
|
+
*/
|
|
15
|
+
color?: string;
|
|
16
|
+
onClick?: () => void;
|
|
17
|
+
}
|
|
18
|
+
export interface BottomNavAction {
|
|
19
|
+
/** Short visual label under the "+" (e.g. "List", "Match"). */
|
|
20
|
+
label: string;
|
|
21
|
+
/**
|
|
22
|
+
* Accessible name for the action control, announced to screen-reader and
|
|
23
|
+
* voice-control users. Use a verb phrase ("New list", "Log match") so the
|
|
24
|
+
* button isn't read as a bare noun confusable with a tab. Falls back to
|
|
25
|
+
* `label`. The visual label always stays `label`; per WCAG 2.5.3, keep
|
|
26
|
+
* `label`'s text inside `ariaLabel` (e.g. label "List" → ariaLabel "New list").
|
|
27
|
+
*/
|
|
28
|
+
ariaLabel?: string;
|
|
29
|
+
/** The "+" glyph (white, ~26px, stroke 2.4). Inherits white from the button. */
|
|
30
|
+
icon: ReactNode;
|
|
31
|
+
onClick?: () => void;
|
|
32
|
+
}
|
|
33
|
+
export interface BottomNavProps {
|
|
34
|
+
/** 2–8 tabs, split evenly around the center action. */
|
|
35
|
+
tabs: BottomNavTab[];
|
|
36
|
+
/** The floating center "+" button and its label. */
|
|
37
|
+
action: BottomNavAction;
|
|
38
|
+
/** Optional grouped-header band; even tab counts only. */
|
|
39
|
+
groups?: [string, string];
|
|
40
|
+
}
|
|
41
|
+
export declare function BottomNav({ tabs, action, groups }: BottomNavProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* The shared SkipLeague bottom navigation (design_handoff_bottom_nav).
|
|
4
|
+
*
|
|
5
|
+
* ONE layout for every app: a row of tabs split evenly around a floating
|
|
6
|
+
* center action ("+"). Only the tab labels/icons and the action's label change
|
|
7
|
+
* per app — spacing, sizing, the button, and its clipped glow are identical
|
|
8
|
+
* everywhere, so the bar looks the same across the family.
|
|
9
|
+
*
|
|
10
|
+
* [tab][tab] ... ( + ) ... [tab][tab]
|
|
11
|
+
* label
|
|
12
|
+
*
|
|
13
|
+
* - `tabs` holds 2–8 tabs. They split around the center: the first
|
|
14
|
+
* `ceil(n/2)` render left of the "+", the rest render right. (For an odd
|
|
15
|
+
* count the extra tab sits on the left; the "+" tracks the true center of
|
|
16
|
+
* its column, so it drifts slightly right of screen-center — even counts are
|
|
17
|
+
* the designed-for case.)
|
|
18
|
+
* - `action` is the floating center button and the word beneath it.
|
|
19
|
+
* - `groups` (optional, even counts only) adds a header band labelling the left
|
|
20
|
+
* pair/triple vs the right. Total bar height is unchanged — the tabs shift
|
|
21
|
+
* down to make room.
|
|
22
|
+
*/
|
|
23
|
+
const ICON = 24; // tab icon box (24px line icons, stroke 2)
|
|
24
|
+
const LABEL_GAP = 10; // icon -> label vertical gap
|
|
25
|
+
const ACTION_SIZE = 53; // floating center button (square)
|
|
26
|
+
const ACTION_RADIUS = 17; // center button corner radius
|
|
27
|
+
const ACTION_OVERHANG = 10; // px the button rises above the bar's top edge
|
|
28
|
+
const PLUS = 26; // "+" glyph box inside the button
|
|
29
|
+
// Soft mint halo + a grounding shadow. Painted on a layer INSIDE the
|
|
30
|
+
// overflow:hidden bar so it is clipped at the top edge — no glow above the line.
|
|
31
|
+
const ACTION_GLOW = "0 0 22px 4px rgba(94,234,212,0.6), 0 10px 22px rgba(15,118,110,0.30)";
|
|
32
|
+
// Two vertical-spacing presets that yield the SAME total bar height.
|
|
33
|
+
const PLAIN = { padTop: 10, padBottom: 18, headerGap: 0 };
|
|
34
|
+
const GROUPED = { padTop: 5, padBottom: 6, headerGap: 5 };
|
|
35
|
+
const cell = {
|
|
36
|
+
display: "flex",
|
|
37
|
+
flexDirection: "column",
|
|
38
|
+
alignItems: "center",
|
|
39
|
+
gap: LABEL_GAP,
|
|
40
|
+
};
|
|
41
|
+
const labelBase = {
|
|
42
|
+
fontFamily: "var(--skl-font-sans)",
|
|
43
|
+
fontSize: 13,
|
|
44
|
+
lineHeight: 1.15,
|
|
45
|
+
};
|
|
46
|
+
const groupLabel = {
|
|
47
|
+
...labelBase,
|
|
48
|
+
fontSize: 11,
|
|
49
|
+
fontWeight: 700,
|
|
50
|
+
letterSpacing: "0.1em",
|
|
51
|
+
textTransform: "uppercase",
|
|
52
|
+
color: "var(--skl-color-text-faint)",
|
|
53
|
+
textAlign: "center",
|
|
54
|
+
};
|
|
55
|
+
function Tab({ label, icon, active, color, onClick, lift }) {
|
|
56
|
+
const accent = color ?? "var(--skl-color-brand)";
|
|
57
|
+
return (_jsxs("button", { type: "button", onClick: onClick, style: {
|
|
58
|
+
...cell,
|
|
59
|
+
background: "none",
|
|
60
|
+
border: "none",
|
|
61
|
+
padding: 0,
|
|
62
|
+
cursor: "pointer",
|
|
63
|
+
transform: `translateY(${lift}px)`,
|
|
64
|
+
// icon color (currentColor); the label sets its own color below
|
|
65
|
+
color: active ? accent : "var(--skl-color-text-faint)",
|
|
66
|
+
}, children: [_jsx("span", { style: { height: ICON, display: "flex", alignItems: "flex-end" }, children: icon }), _jsx("span", { style: {
|
|
67
|
+
...labelBase,
|
|
68
|
+
fontWeight: active ? 700 : 600,
|
|
69
|
+
color: active ? accent : "var(--skl-color-text-muted)",
|
|
70
|
+
}, children: label })] }));
|
|
71
|
+
}
|
|
72
|
+
export function BottomNav({ tabs, action, groups }) {
|
|
73
|
+
const n = tabs.length;
|
|
74
|
+
if (n < 2 || n > 8) {
|
|
75
|
+
// Render what we were given, but flag the contract violation in dev.
|
|
76
|
+
console.warn(`BottomNav expects 2–8 tabs, received ${n}.`);
|
|
77
|
+
}
|
|
78
|
+
// Split around the center: extra tab (odd n) goes left.
|
|
79
|
+
const leftCount = Math.ceil(n / 2);
|
|
80
|
+
const left = tabs.slice(0, leftCount);
|
|
81
|
+
const right = tabs.slice(leftCount);
|
|
82
|
+
const columns = n + 1; // tabs + the center label column
|
|
83
|
+
// Grouped headers are an even-count feature (a header over each side).
|
|
84
|
+
const grouped = Array.isArray(groups) && groups.length === 2 && n % 2 === 0;
|
|
85
|
+
const sp = grouped ? GROUPED : PLAIN;
|
|
86
|
+
// In grouped mode the tab row sits lower; lift the center label back in line
|
|
87
|
+
// with the side tabs (right under the "+"), and nudge the tabs up for balance.
|
|
88
|
+
const centerLabelLift = grouped ? -13 : 0;
|
|
89
|
+
const tabLift = grouped ? -2 : 0;
|
|
90
|
+
// Horizontal center of the action column (== 50% for even counts).
|
|
91
|
+
const actionLeft = `${((leftCount + 0.5) / columns) * 100}%`;
|
|
92
|
+
const gridCols = `repeat(${columns}, 1fr)`;
|
|
93
|
+
// "Tab Colors": the center button + label + glow follow the active tab's color
|
|
94
|
+
// when one is set; otherwise everything stays the default brand teal + mint glow.
|
|
95
|
+
const activeTab = tabs.find((t) => t.active);
|
|
96
|
+
const accent = activeTab?.color ?? "var(--skl-color-brand)";
|
|
97
|
+
const glow = activeTab?.color
|
|
98
|
+
? `0 0 22px 4px color-mix(in srgb, ${activeTab.color} 55%, transparent), 0 10px 22px color-mix(in srgb, ${activeTab.color} 30%, transparent)`
|
|
99
|
+
: ACTION_GLOW;
|
|
100
|
+
return (_jsxs("nav", { style: { position: "relative", flex: "none" }, children: [_jsxs("div", { style: {
|
|
101
|
+
background: "var(--skl-color-surface)",
|
|
102
|
+
borderTop: "1px solid var(--skl-color-border)",
|
|
103
|
+
position: "relative",
|
|
104
|
+
overflow: "hidden",
|
|
105
|
+
// Keep the tabs clear of the iOS home indicator (additive to padBottom).
|
|
106
|
+
paddingBottom: "env(safe-area-inset-bottom, 0px)",
|
|
107
|
+
}, children: [_jsx("span", { "aria-hidden": true, style: {
|
|
108
|
+
position: "absolute",
|
|
109
|
+
top: -ACTION_OVERHANG,
|
|
110
|
+
left: actionLeft,
|
|
111
|
+
transform: "translateX(-50%)",
|
|
112
|
+
width: ACTION_SIZE,
|
|
113
|
+
height: ACTION_SIZE,
|
|
114
|
+
borderRadius: ACTION_RADIUS,
|
|
115
|
+
boxShadow: glow,
|
|
116
|
+
pointerEvents: "none",
|
|
117
|
+
} }), grouped && (_jsxs("div", { style: { display: "grid", gridTemplateColumns: gridCols, paddingTop: sp.padTop }, children: [_jsx("div", { style: { ...groupLabel, gridColumn: `1 / ${leftCount + 1}` }, children: groups[0] }), _jsx("div", { style: { ...groupLabel, gridColumn: `${leftCount + 2} / ${columns + 1}` }, children: groups[1] })] })), _jsxs("div", { style: {
|
|
118
|
+
display: "grid",
|
|
119
|
+
gridTemplateColumns: gridCols,
|
|
120
|
+
paddingTop: grouped ? sp.headerGap : sp.padTop,
|
|
121
|
+
paddingBottom: sp.padBottom,
|
|
122
|
+
position: "relative",
|
|
123
|
+
}, children: [left.map((t, i) => (_jsx(Tab, { ...t, lift: tabLift }, `l${i}`))), _jsxs("div", { onClick: action.onClick, "aria-hidden": true, style: { ...cell, cursor: "pointer" }, children: [_jsx("div", { style: { height: ICON } }), _jsx("span", { style: {
|
|
124
|
+
...labelBase,
|
|
125
|
+
fontWeight: 700,
|
|
126
|
+
color: accent,
|
|
127
|
+
transform: `translateY(${centerLabelLift}px)`,
|
|
128
|
+
}, children: action.label })] }), right.map((t, i) => (_jsx(Tab, { ...t, lift: tabLift }, `r${i}`)))] })] }), _jsx("button", { type: "button", onClick: action.onClick, "aria-label": action.ariaLabel ?? action.label, style: {
|
|
129
|
+
position: "absolute",
|
|
130
|
+
top: -ACTION_OVERHANG,
|
|
131
|
+
left: actionLeft,
|
|
132
|
+
transform: "translateX(-50%)",
|
|
133
|
+
width: ACTION_SIZE,
|
|
134
|
+
height: ACTION_SIZE,
|
|
135
|
+
borderRadius: ACTION_RADIUS,
|
|
136
|
+
background: accent,
|
|
137
|
+
color: "#fff",
|
|
138
|
+
border: "none",
|
|
139
|
+
display: "flex",
|
|
140
|
+
alignItems: "center",
|
|
141
|
+
justifyContent: "center",
|
|
142
|
+
zIndex: 3,
|
|
143
|
+
cursor: "pointer",
|
|
144
|
+
}, children: _jsx("span", { style: { display: "flex", width: PLUS, height: PLUS }, children: action.icon }) })] }));
|
|
145
|
+
}
|
package/dist/Button.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ButtonHTMLAttributes } from "react";
|
|
2
|
+
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
3
|
+
/** "primary" = brand-green fill (default); "secondary" = white with a border. */
|
|
4
|
+
variant?: "primary" | "secondary";
|
|
5
|
+
}
|
|
6
|
+
/** The standard SkipLeague button. Token-driven; matches the apps' primary action. */
|
|
7
|
+
export declare function Button({ variant, style, disabled, ...props }: ButtonProps): import("react").JSX.Element;
|
package/dist/Button.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
/** The standard SkipLeague button. Token-driven; matches the apps' primary action. */
|
|
3
|
+
export function Button({ variant = "primary", style, disabled, ...props }) {
|
|
4
|
+
const base = {
|
|
5
|
+
display: "inline-flex",
|
|
6
|
+
alignItems: "center",
|
|
7
|
+
justifyContent: "center",
|
|
8
|
+
gap: "0.5rem",
|
|
9
|
+
fontFamily: "var(--skl-font-sans)",
|
|
10
|
+
fontSize: "var(--skl-text-sm)",
|
|
11
|
+
fontWeight: 600,
|
|
12
|
+
padding: "0.625rem 1.25rem",
|
|
13
|
+
borderRadius: "var(--skl-radius-md)",
|
|
14
|
+
cursor: disabled ? "default" : "pointer",
|
|
15
|
+
opacity: disabled ? 0.6 : 1,
|
|
16
|
+
border: "1px solid transparent",
|
|
17
|
+
...(variant === "primary"
|
|
18
|
+
? { background: "var(--skl-color-brand)", color: "#fff" }
|
|
19
|
+
: { background: "var(--skl-color-surface)", color: "var(--skl-color-text)", borderColor: "var(--skl-color-border)" }),
|
|
20
|
+
};
|
|
21
|
+
return _jsx("button", { ...props, disabled: disabled, style: { ...base, ...style } });
|
|
22
|
+
}
|
package/dist/Card.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { CSSProperties, ReactNode } from "react";
|
|
2
|
+
/**
|
|
3
|
+
* A section card: an optional heading above a white, bordered, rounded container —
|
|
4
|
+
* the standard grouping used on the account/settings pages.
|
|
5
|
+
*/
|
|
6
|
+
export declare function Card({ title, children, style, }: {
|
|
7
|
+
title?: string;
|
|
8
|
+
children: ReactNode;
|
|
9
|
+
style?: CSSProperties;
|
|
10
|
+
}): import("react").JSX.Element;
|
package/dist/Card.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* A section card: an optional heading above a white, bordered, rounded container —
|
|
4
|
+
* the standard grouping used on the account/settings pages.
|
|
5
|
+
*/
|
|
6
|
+
export function Card({ title, children, style, }) {
|
|
7
|
+
return (_jsxs("section", { style: { fontFamily: "var(--skl-font-sans)", ...style }, children: [title && (_jsx("h3", { style: { margin: "0 0 0.75rem", fontSize: "1rem", fontWeight: 600, color: "var(--skl-color-text)" }, children: title })), _jsx("div", { style: {
|
|
8
|
+
background: "var(--skl-color-surface)",
|
|
9
|
+
border: "1px solid var(--skl-color-border)",
|
|
10
|
+
borderRadius: "var(--skl-radius-panel)",
|
|
11
|
+
padding: "1rem",
|
|
12
|
+
}, children: children })] }));
|
|
13
|
+
}
|
package/dist/Field.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { InputHTMLAttributes, ReactNode } from "react";
|
|
2
|
+
/** A labeled form row: a label, the control (children), and an optional hint. */
|
|
3
|
+
export declare function Field({ label, hint, children, }: {
|
|
4
|
+
label: string;
|
|
5
|
+
hint?: ReactNode;
|
|
6
|
+
children: ReactNode;
|
|
7
|
+
}): import("react").JSX.Element;
|
|
8
|
+
/** A styled text input. */
|
|
9
|
+
export declare function Input({ style, disabled, ...props }: InputHTMLAttributes<HTMLInputElement>): import("react").JSX.Element;
|
package/dist/Field.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { controlStyle } from "./styles.js";
|
|
3
|
+
/** A labeled form row: a label, the control (children), and an optional hint. */
|
|
4
|
+
export function Field({ label, hint, children, }) {
|
|
5
|
+
return (_jsxs("div", { style: { fontFamily: "var(--skl-font-sans)" }, children: [_jsx("label", { style: { display: "block", fontSize: "var(--skl-text-sm)", fontWeight: 500, marginBottom: 4, color: "var(--skl-color-text)" }, children: label }), children, hint && (_jsx("p", { style: { margin: "0.25rem 0 0", fontSize: "var(--skl-text-xs)", color: "var(--skl-color-text-muted)" }, children: hint }))] }));
|
|
6
|
+
}
|
|
7
|
+
/** A styled text input. */
|
|
8
|
+
export function Input({ style, disabled, ...props }) {
|
|
9
|
+
return (_jsx("input", { ...props, disabled: disabled, style: {
|
|
10
|
+
...controlStyle,
|
|
11
|
+
...(disabled ? { background: "var(--skl-color-surface-muted)", color: "var(--skl-color-text-muted)" } : {}),
|
|
12
|
+
...style,
|
|
13
|
+
} }));
|
|
14
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { type CSSProperties, type ReactNode } from "react";
|
|
2
|
+
import { type AppLink } from "./apps.js";
|
|
3
|
+
export interface ProfileMenuUser {
|
|
4
|
+
displayName?: string | null;
|
|
5
|
+
email?: string | null;
|
|
6
|
+
}
|
|
7
|
+
/** Args passed to a custom `renderLink` for internal (in-app) menu links. */
|
|
8
|
+
export interface ProfileMenuLinkArgs {
|
|
9
|
+
href: string;
|
|
10
|
+
role?: string;
|
|
11
|
+
style?: CSSProperties;
|
|
12
|
+
children: ReactNode;
|
|
13
|
+
}
|
|
14
|
+
export interface ProfileMenuProps {
|
|
15
|
+
/** The signed-in user (name + email shown at the top of the menu). */
|
|
16
|
+
user?: ProfileMenuUser;
|
|
17
|
+
/**
|
|
18
|
+
* Slug of the app you're currently in (e.g. "skipracquetball"). That app's row
|
|
19
|
+
* is highlighted light-green and non-clickable. Omit on the platform apex.
|
|
20
|
+
*/
|
|
21
|
+
currentSlug?: string;
|
|
22
|
+
/** Apps to list in the switcher. Defaults to the live SkipLeague apps. */
|
|
23
|
+
apps?: AppLink[];
|
|
24
|
+
/**
|
|
25
|
+
* Slugs of the apps THIS user has enabled (e.g. the platform's `app_slugs`).
|
|
26
|
+
* When provided, the switcher shows only those apps (the current app is always
|
|
27
|
+
* kept), so every app shows the same "your apps" set without each one
|
|
28
|
+
* re-implementing the filter. Omit to list every app in `apps`.
|
|
29
|
+
*/
|
|
30
|
+
enabledSlugs?: string[];
|
|
31
|
+
/** Target of the "Manage account" link. Defaults to the platform account page. */
|
|
32
|
+
accountUrl?: string;
|
|
33
|
+
/** Called when the user clicks "Sign out". */
|
|
34
|
+
onSignOut: () => void;
|
|
35
|
+
/**
|
|
36
|
+
* Header tone the trigger button sits on — "dark" (default) for the apex/dark
|
|
37
|
+
* headers, "light" for white headers. Only affects the button, not the menu.
|
|
38
|
+
*/
|
|
39
|
+
tone?: "light" | "dark";
|
|
40
|
+
/**
|
|
41
|
+
* Render the internal "Manage account" link via your own router (e.g. React
|
|
42
|
+
* Router's `<Link>`) instead of a full-page `<a href>`. App-switcher links to
|
|
43
|
+
* OTHER SkipLeague apps are always plain `<a>` (cross-app navigation).
|
|
44
|
+
* Example: `renderLink={({ href, ...p }) => <Link to={href} {...p} />}`.
|
|
45
|
+
*/
|
|
46
|
+
renderLink?: (args: ProfileMenuLinkArgs) => ReactNode;
|
|
47
|
+
/**
|
|
48
|
+
* Mark "Manage account" as the current page — highlighted light-green and
|
|
49
|
+
* non-clickable (e.g. the platform is already on /account).
|
|
50
|
+
*/
|
|
51
|
+
accountIsCurrent?: boolean;
|
|
52
|
+
/**
|
|
53
|
+
* Signed-out support (platform apex): when there's no `user` AND this is
|
|
54
|
+
* provided, the menu shows a single "Sign in" action instead of the
|
|
55
|
+
* name / Manage-account / Sign-out items. Omit it to keep the always-signed-in
|
|
56
|
+
* behavior product apps rely on.
|
|
57
|
+
*/
|
|
58
|
+
onSignIn?: () => void;
|
|
59
|
+
/** Label for the signed-out action (default "Sign in"). */
|
|
60
|
+
signInLabel?: string;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* The canonical SkipLeague account control: a boxed user-icon button that opens
|
|
64
|
+
* a dropdown with the user's name/email, an inline **app switcher** (one click to
|
|
65
|
+
* jump to another SkipLeague app; the current app is highlighted and not
|
|
66
|
+
* clickable), **Manage account**, and **Sign out**. Also covers the platform
|
|
67
|
+
* apex via `renderLink` (SPA nav), `accountIsCurrent`, and `onSignIn`
|
|
68
|
+
* (signed-out state).
|
|
69
|
+
*
|
|
70
|
+
* Requires `@skipleague/design/tokens.css` to be imported once at the app root.
|
|
71
|
+
*/
|
|
72
|
+
export declare function ProfileMenu({ user, currentSlug, apps, enabledSlugs, accountUrl, onSignOut, tone, renderLink, accountIsCurrent, onSignIn, signInLabel, }: ProfileMenuProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useRef, useState } from "react";
|
|
3
|
+
import { CircleUser, LogIn, LogOut, Settings } from "lucide-react";
|
|
4
|
+
import { AppBadge } from "./AppBadge.js";
|
|
5
|
+
import { SKIPLEAGUE_ACCOUNT_URL, SKIPLEAGUE_APPS } from "./apps.js";
|
|
6
|
+
/**
|
|
7
|
+
* The canonical SkipLeague account control: a boxed user-icon button that opens
|
|
8
|
+
* a dropdown with the user's name/email, an inline **app switcher** (one click to
|
|
9
|
+
* jump to another SkipLeague app; the current app is highlighted and not
|
|
10
|
+
* clickable), **Manage account**, and **Sign out**. Also covers the platform
|
|
11
|
+
* apex via `renderLink` (SPA nav), `accountIsCurrent`, and `onSignIn`
|
|
12
|
+
* (signed-out state).
|
|
13
|
+
*
|
|
14
|
+
* Requires `@skipleague/design/tokens.css` to be imported once at the app root.
|
|
15
|
+
*/
|
|
16
|
+
export function ProfileMenu({ user, currentSlug, apps = SKIPLEAGUE_APPS, enabledSlugs, accountUrl = SKIPLEAGUE_ACCOUNT_URL, onSignOut, tone = "dark", renderLink, accountIsCurrent = false, onSignIn, signInLabel = "Sign in", }) {
|
|
17
|
+
const [open, setOpen] = useState(false);
|
|
18
|
+
const [hover, setHover] = useState(false);
|
|
19
|
+
const ref = useRef(null);
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
if (!open)
|
|
22
|
+
return;
|
|
23
|
+
const onDocClick = (e) => {
|
|
24
|
+
if (ref.current && !ref.current.contains(e.target))
|
|
25
|
+
setOpen(false);
|
|
26
|
+
};
|
|
27
|
+
const onKey = (e) => {
|
|
28
|
+
if (e.key === "Escape")
|
|
29
|
+
setOpen(false);
|
|
30
|
+
};
|
|
31
|
+
document.addEventListener("mousedown", onDocClick);
|
|
32
|
+
document.addEventListener("keydown", onKey);
|
|
33
|
+
return () => {
|
|
34
|
+
document.removeEventListener("mousedown", onDocClick);
|
|
35
|
+
document.removeEventListener("keydown", onKey);
|
|
36
|
+
};
|
|
37
|
+
}, [open]);
|
|
38
|
+
// Show only the apps this user has enabled (always keeping the current app),
|
|
39
|
+
// when the caller passes the user's enabled slugs. Otherwise list every app.
|
|
40
|
+
const visibleApps = enabledSlugs
|
|
41
|
+
? apps.filter((a) => a.slug === currentSlug || enabledSlugs.includes(a.slug))
|
|
42
|
+
: apps;
|
|
43
|
+
const signedIn = !!(user?.displayName || user?.email);
|
|
44
|
+
// Signed-out menu only when a sign-in handler is supplied — otherwise keep the
|
|
45
|
+
// existing always-signed-in behavior product apps depend on.
|
|
46
|
+
const showSignedOut = !signedIn && !!onSignIn;
|
|
47
|
+
const label = signedIn ? user.displayName || user.email || "Account" : "Account";
|
|
48
|
+
const brand = tone === "light" ? "var(--skl-color-brand)" : "var(--skl-color-brand-bright)";
|
|
49
|
+
const idleIcon = tone === "light" ? "#334155" : "#e2e8f0";
|
|
50
|
+
const idleBorder = tone === "light" ? "var(--skl-color-border)" : "rgba(255,255,255,0.18)";
|
|
51
|
+
const boxBg = tone === "light" ? "#ffffff" : "rgba(255,255,255,0.06)";
|
|
52
|
+
const active = open || hover;
|
|
53
|
+
const accountLabel = (_jsxs(_Fragment, { children: [_jsx(Settings, { size: 15 }), " Manage account"] }));
|
|
54
|
+
const accountItem = accountIsCurrent ? (_jsx("div", { "aria-current": "page", style: { ...itemStyle, fontWeight: 600, background: "var(--skl-color-current-bg)", color: "var(--skl-color-current-text)", cursor: "default" }, children: accountLabel })) : renderLink ? (renderLink({ href: accountUrl, role: "menuitem", style: itemStyle, children: accountLabel })) : (_jsx("a", { href: accountUrl, role: "menuitem", style: itemStyle, children: accountLabel }));
|
|
55
|
+
return (_jsxs("div", { ref: ref, style: { position: "relative", fontFamily: "var(--skl-font-sans)" }, children: [_jsx("button", { onClick: () => setOpen((o) => !o), onMouseEnter: () => setHover(true), onMouseLeave: () => setHover(false), "aria-haspopup": "menu", "aria-expanded": open, "aria-label": "Account", title: label, style: {
|
|
56
|
+
display: "inline-flex",
|
|
57
|
+
alignItems: "center",
|
|
58
|
+
justifyContent: "center",
|
|
59
|
+
width: 38,
|
|
60
|
+
height: 38,
|
|
61
|
+
borderRadius: "var(--skl-radius-control)",
|
|
62
|
+
cursor: "pointer",
|
|
63
|
+
border: `1px solid ${active ? brand : idleBorder}`,
|
|
64
|
+
background: boxBg,
|
|
65
|
+
color: active ? brand : idleIcon,
|
|
66
|
+
transition: "border-color 0.15s, color 0.15s",
|
|
67
|
+
}, 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
|
+
const isCurrent = a.slug === currentSlug;
|
|
69
|
+
const inner = (_jsxs("span", { style: { display: "flex", alignItems: "center", gap: "0.5rem" }, children: [_jsx(AppBadge, { name: a.name }), a.name] }));
|
|
70
|
+
// The app you're in: light-green highlight, not clickable.
|
|
71
|
+
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
|
+
})] })), _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"] })] })) }))] }));
|
|
73
|
+
}
|
|
74
|
+
const menuStyle = {
|
|
75
|
+
position: "absolute",
|
|
76
|
+
right: 0,
|
|
77
|
+
top: "calc(100% + 8px)",
|
|
78
|
+
minWidth: 200,
|
|
79
|
+
background: "var(--skl-color-surface)",
|
|
80
|
+
border: "1px solid var(--skl-color-border)",
|
|
81
|
+
borderRadius: "var(--skl-radius-panel)",
|
|
82
|
+
boxShadow: "var(--skl-shadow-menu)",
|
|
83
|
+
overflow: "hidden",
|
|
84
|
+
zIndex: 50,
|
|
85
|
+
};
|
|
86
|
+
const itemStyle = {
|
|
87
|
+
display: "flex",
|
|
88
|
+
alignItems: "center",
|
|
89
|
+
gap: "0.5rem",
|
|
90
|
+
padding: "0.55rem 0.75rem",
|
|
91
|
+
fontSize: "var(--skl-text-sm)",
|
|
92
|
+
color: "var(--skl-color-text)",
|
|
93
|
+
textDecoration: "none",
|
|
94
|
+
};
|
|
95
|
+
const switchHeading = {
|
|
96
|
+
padding: "0.5rem 0.75rem 0.25rem",
|
|
97
|
+
fontSize: "var(--skl-text-2xs)",
|
|
98
|
+
fontWeight: 600,
|
|
99
|
+
color: "var(--skl-color-text-faint)",
|
|
100
|
+
textTransform: "uppercase",
|
|
101
|
+
letterSpacing: 0.5,
|
|
102
|
+
};
|
|
103
|
+
const divider = {
|
|
104
|
+
borderTop: "1px solid var(--skl-color-border)",
|
|
105
|
+
margin: "0.25rem 0",
|
|
106
|
+
};
|
package/dist/Select.d.ts
ADDED
package/dist/Select.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { controlStyle } from "./styles.js";
|
|
3
|
+
/** A styled select. Pass `<option>`s as children. */
|
|
4
|
+
export function Select({ style, children, ...props }) {
|
|
5
|
+
return (_jsx("select", { ...props, style: { ...controlStyle, ...style }, children: children }));
|
|
6
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The top-bar Share dropdown (design_handoff_top_bar). A self-contained trigger
|
|
3
|
+
* (the upload glyph, styled like a {@link TopBarIconButton}) + an anchored menu —
|
|
4
|
+
* same open/dismiss model as ProfileMenu (click, outside-click, Escape).
|
|
5
|
+
*
|
|
6
|
+
* `noun` names the thing being shared ("players" → "Share players"). Pass the
|
|
7
|
+
* handlers you support; omitted items are hidden.
|
|
8
|
+
*/
|
|
9
|
+
export declare function ShareMenu({ noun, compact, onCopyLink, onMessages, onEmail, onMore, }: {
|
|
10
|
+
noun: string;
|
|
11
|
+
compact?: boolean;
|
|
12
|
+
onCopyLink?: () => void;
|
|
13
|
+
onMessages?: () => void;
|
|
14
|
+
onEmail?: () => void;
|
|
15
|
+
onMore?: () => void;
|
|
16
|
+
}): import("react").JSX.Element;
|