@bundu/ui 0.1.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/LICENSE +21 -0
- package/README.md +97 -0
- package/package.json +79 -0
- package/src/Breadcrumb.astro +105 -0
- package/src/Container.astro +39 -0
- package/src/Hero.astro +101 -0
- package/src/Icon.astro +50 -0
- package/src/MineralStrip.astro +51 -0
- package/src/Section.astro +59 -0
- package/src/SectionHeader.astro +73 -0
- package/src/SocialIcon.astro +166 -0
- package/src/breadcrumbs.ts +83 -0
- package/src/index.ts +5 -0
- package/src/lib/utils.ts +44 -0
- package/src/ui/alert.tsx +99 -0
- package/src/ui/avatar.tsx +69 -0
- package/src/ui/badge.tsx +58 -0
- package/src/ui/button.tsx +114 -0
- package/src/ui/card.tsx +63 -0
- package/src/ui/checkbox.tsx +40 -0
- package/src/ui/input.tsx +34 -0
- package/src/ui/label.tsx +41 -0
- package/src/ui/select.tsx +41 -0
- package/src/ui/separator.tsx +45 -0
- package/src/ui/skeleton.tsx +30 -0
- package/src/ui/switch.tsx +101 -0
- package/src/ui/tabs.tsx +199 -0
- package/src/ui/textarea.tsx +34 -0
- package/src/ui/tooltip.tsx +78 -0
- package/styles/brand-bundu.css +10 -0
- package/styles/brand-mukoko.css +10 -0
- package/styles/brand-nyuchi.css +10 -0
- package/styles/globals.css +358 -0
- package/tailwind-preset.mjs +177 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* SocialIcon — platform-aware social link.
|
|
4
|
+
*
|
|
5
|
+
* Detects the platform from the URL hostname and renders the right
|
|
6
|
+
* SVG glyph + accessible label. Falls back to a generic globe icon
|
|
7
|
+
* when the hostname doesn't match any known platform — the link
|
|
8
|
+
* still works; only the icon defaults.
|
|
9
|
+
*
|
|
10
|
+
* Used on /team across bundu, nyuchi, mukoko (one record per
|
|
11
|
+
* author in Sanity → many platform links per author). The Sanity
|
|
12
|
+
* field is `sameAs: string[]` on the author doc, so editors don't
|
|
13
|
+
* have to pick a platform from a dropdown — they just paste the
|
|
14
|
+
* URL and the render picks the right icon.
|
|
15
|
+
*
|
|
16
|
+
* Supported platforms: LinkedIn, X (Twitter), Instagram, GitHub,
|
|
17
|
+
* YouTube, Threads, Bluesky, Mastodon, Facebook, TikTok, ORCID,
|
|
18
|
+
* plus a generic fallback. Add a host → key mapping below to
|
|
19
|
+
* extend.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
interface Props {
|
|
23
|
+
/** Full URL to the social profile. The protocol must be present
|
|
24
|
+
* (https://...); we pass it through to the anchor href. */
|
|
25
|
+
url: string;
|
|
26
|
+
/** Icon size in px. Default 18, which sits well in a 36px round
|
|
27
|
+
* button (margin handled by the wrapping `.w-9 h-9`). */
|
|
28
|
+
size?: number;
|
|
29
|
+
/** Override the auto-detected label (otherwise the platform name
|
|
30
|
+
* or hostname is used for aria-label + title). */
|
|
31
|
+
label?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const { url, size = 18, label } = Astro.props;
|
|
35
|
+
|
|
36
|
+
function getPlatform(rawUrl: string): { name: string; key: string } | null {
|
|
37
|
+
let host: string;
|
|
38
|
+
try {
|
|
39
|
+
host = new URL(rawUrl).hostname.toLowerCase().replace(/^www\./, '');
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
const exactMap: Record<string, { name: string; key: string }> = {
|
|
44
|
+
'linkedin.com': { name: 'LinkedIn', key: 'linkedin' },
|
|
45
|
+
'lnkd.in': { name: 'LinkedIn', key: 'linkedin' },
|
|
46
|
+
'x.com': { name: 'X', key: 'x' },
|
|
47
|
+
'twitter.com': { name: 'X', key: 'x' },
|
|
48
|
+
't.co': { name: 'X', key: 'x' },
|
|
49
|
+
'instagram.com': { name: 'Instagram', key: 'instagram' },
|
|
50
|
+
'github.com': { name: 'GitHub', key: 'github' },
|
|
51
|
+
'youtube.com': { name: 'YouTube', key: 'youtube' },
|
|
52
|
+
'youtu.be': { name: 'YouTube', key: 'youtube' },
|
|
53
|
+
'threads.net': { name: 'Threads', key: 'threads' },
|
|
54
|
+
'bsky.app': { name: 'Bluesky', key: 'bluesky' },
|
|
55
|
+
'facebook.com': { name: 'Facebook', key: 'facebook' },
|
|
56
|
+
'fb.me': { name: 'Facebook', key: 'facebook' },
|
|
57
|
+
'tiktok.com': { name: 'TikTok', key: 'tiktok' },
|
|
58
|
+
'orcid.org': { name: 'ORCID', key: 'orcid' },
|
|
59
|
+
};
|
|
60
|
+
if (exactMap[host]) return exactMap[host];
|
|
61
|
+
// Mastodon federation: every server has its own hostname. Match
|
|
62
|
+
// common patterns (mastodon.*, *.social) rather than enumerating.
|
|
63
|
+
if (/(^|\.)mastodon\./.test(host) || host.endsWith('.social')) {
|
|
64
|
+
return { name: 'Mastodon', key: 'mastodon' };
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let hostname = '';
|
|
70
|
+
try {
|
|
71
|
+
hostname = new URL(url).hostname.replace(/^www\./, '');
|
|
72
|
+
} catch {
|
|
73
|
+
// leave hostname empty; the link still renders, just falls back
|
|
74
|
+
// to the generic icon below
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const platform = getPlatform(url);
|
|
78
|
+
const displayLabel = label ?? platform?.name ?? hostname ?? 'External link';
|
|
79
|
+
const key = platform?.key ?? 'generic';
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
<a
|
|
83
|
+
href={url}
|
|
84
|
+
target="_blank"
|
|
85
|
+
rel="me noopener noreferrer"
|
|
86
|
+
aria-label={displayLabel}
|
|
87
|
+
title={displayLabel}
|
|
88
|
+
class="inline-flex items-center justify-center w-9 h-9 rounded-full text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
|
89
|
+
data-social-platform={key}
|
|
90
|
+
>
|
|
91
|
+
{key === 'linkedin' && (
|
|
92
|
+
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
|
93
|
+
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.062 2.062 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>
|
|
94
|
+
</svg>
|
|
95
|
+
)}
|
|
96
|
+
|
|
97
|
+
{key === 'x' && (
|
|
98
|
+
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
|
99
|
+
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
|
|
100
|
+
</svg>
|
|
101
|
+
)}
|
|
102
|
+
|
|
103
|
+
{key === 'instagram' && (
|
|
104
|
+
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
105
|
+
<rect x="2" y="2" width="20" height="20" rx="5" ry="5"/>
|
|
106
|
+
<path d="M16 11.37A4 4 0 1 1 12.63 8 4 4 0 0 1 16 11.37z"/>
|
|
107
|
+
<line x1="17.5" y1="6.5" x2="17.51" y2="6.5"/>
|
|
108
|
+
</svg>
|
|
109
|
+
)}
|
|
110
|
+
|
|
111
|
+
{key === 'github' && (
|
|
112
|
+
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
|
113
|
+
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
|
|
114
|
+
</svg>
|
|
115
|
+
)}
|
|
116
|
+
|
|
117
|
+
{key === 'youtube' && (
|
|
118
|
+
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
|
119
|
+
<path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/>
|
|
120
|
+
</svg>
|
|
121
|
+
)}
|
|
122
|
+
|
|
123
|
+
{key === 'threads' && (
|
|
124
|
+
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
|
125
|
+
<path d="M12.186 24h-.007c-3.581-.024-6.334-1.205-8.184-3.509C2.35 18.44 1.5 15.586 1.472 12.01v-.017c.03-3.579.879-6.43 2.525-8.482C5.845 1.205 8.6.024 12.18 0h.014c2.746.02 5.043.725 6.826 2.098 1.677 1.29 2.858 3.13 3.509 5.467l-2.04.569c-1.104-3.96-3.898-5.984-8.304-6.015-2.91.022-5.11.936-6.54 2.717C4.307 6.504 3.616 8.914 3.589 12c.027 3.086.718 5.496 2.057 7.164 1.43 1.781 3.631 2.695 6.54 2.717 2.623-.02 4.358-.631 5.8-2.045 1.647-1.613 1.618-3.593 1.09-4.798-.31-.71-.873-1.3-1.634-1.75-.192 1.352-.622 2.446-1.284 3.272-.886 1.102-2.14 1.704-3.73 1.79-1.202.065-2.361-.218-3.259-.801-1.063-.689-1.685-1.74-1.752-2.964-.065-1.19.408-2.285 1.33-3.082.88-.76 2.119-1.207 3.583-1.291a13.853 13.853 0 0 1 3.02.142c-.126-.742-.375-1.332-.75-1.757-.513-.586-1.308-.883-2.359-.89h-.029c-.844 0-1.992.232-2.721 1.32L7.734 7.847c.98-1.454 2.568-2.256 4.478-2.256h.044c3.194.02 5.097 1.975 5.287 5.388.108.046.216.094.32.143 1.49.7 2.58 1.761 3.154 3.07.797 1.82.871 4.79-1.548 7.158-1.85 1.81-4.094 2.628-7.277 2.65zm1.003-11.69c-.242 0-.487.007-.739.021-1.836.103-2.98.945-2.916 2.143.067 1.256 1.452 1.84 2.784 1.767 1.224-.065 2.818-.543 3.086-3.71a10.5 10.5 0 0 0-2.215-.221z"/>
|
|
126
|
+
</svg>
|
|
127
|
+
)}
|
|
128
|
+
|
|
129
|
+
{key === 'bluesky' && (
|
|
130
|
+
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
|
131
|
+
<path d="M12 10.8c-1.087-2.114-4.046-6.053-6.798-7.995C2.566.944 1.561 1.266.902 1.565.139 1.908 0 3.08 0 3.768c0 .69.378 5.65.624 6.479.815 2.736 3.713 3.66 6.383 3.364.136-.02.275-.039.415-.056-.138.022-.276.04-.415.056-3.912.58-7.387 2.005-2.83 7.078 5.013 5.19 6.87-1.113 7.823-4.308.953 3.195 2.05 9.271 7.733 4.308 4.267-4.308 1.172-6.498-2.74-7.078a8.741 8.741 0 0 1-.415-.056c.14.017.279.036.415.056 2.67.297 5.568-.628 6.383-3.364.246-.828.624-5.79.624-6.478 0-.69-.139-1.861-.902-2.206-.659-.298-1.664-.62-4.3 1.24C16.046 4.748 13.087 8.687 12 10.8Z"/>
|
|
132
|
+
</svg>
|
|
133
|
+
)}
|
|
134
|
+
|
|
135
|
+
{key === 'mastodon' && (
|
|
136
|
+
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
|
137
|
+
<path d="M23.193 7.88c0-5.207-3.412-6.733-3.412-6.733C18.062.357 15.108.027 12.041 0h-.076c-3.069.027-6.02.357-7.74 1.147 0 0-3.412 1.526-3.412 6.732 0 1.193-.023 2.619.015 4.13.124 5.092.934 10.11 5.641 11.355 2.17.574 4.034.695 5.535.612 2.722-.15 4.25-.972 4.25-.972l-.09-1.975s-1.945.613-4.13.539c-2.165-.074-4.449-.234-4.799-2.892a5.45 5.45 0 0 1-.048-.745s2.125.52 4.82.643c1.647.076 3.193-.097 4.762-.283 3.007-.36 5.626-2.216 5.956-3.913.52-2.673.477-6.521.477-6.521zm-4.024 6.704h-2.497V8.469c0-1.29-.541-1.944-1.628-1.944-1.2 0-1.802.776-1.802 2.312v3.349h-2.484v-3.35c0-1.535-.602-2.31-1.802-2.31-1.087 0-1.628.653-1.628 1.943v6.115H4.831V8.285c0-1.29.328-2.314.987-3.07.68-.757 1.57-1.146 2.674-1.146 1.278 0 2.246.491 2.886 1.474L12 6.585l.622-1.042c.64-.983 1.608-1.474 2.886-1.474 1.104 0 1.994.389 2.674 1.146.658.756.987 1.78.987 3.07z"/>
|
|
138
|
+
</svg>
|
|
139
|
+
)}
|
|
140
|
+
|
|
141
|
+
{key === 'facebook' && (
|
|
142
|
+
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
|
143
|
+
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
|
|
144
|
+
</svg>
|
|
145
|
+
)}
|
|
146
|
+
|
|
147
|
+
{key === 'tiktok' && (
|
|
148
|
+
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
|
149
|
+
<path d="M19.59 6.69a4.83 4.83 0 0 1-3.77-4.25V2h-3.45v13.67a2.89 2.89 0 0 1-5.2 1.74 2.89 2.89 0 0 1 2.31-4.64 2.93 2.93 0 0 1 .88.13V9.4a6.84 6.84 0 0 0-1-.05A6.33 6.33 0 0 0 5.8 20.1a6.34 6.34 0 0 0 10.86-4.43v-7a8.16 8.16 0 0 0 4.77 1.52v-3.4a4.85 4.85 0 0 1-1.84-.1z"/>
|
|
150
|
+
</svg>
|
|
151
|
+
)}
|
|
152
|
+
|
|
153
|
+
{key === 'orcid' && (
|
|
154
|
+
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
|
155
|
+
<path d="M12 0C5.372 0 0 5.372 0 12s5.372 12 12 12 12-5.372 12-12S18.628 0 12 0zM7.369 4.378c.525 0 .947.431.947.947 0 .525-.422.947-.947.947-.525 0-.946-.422-.946-.947 0-.516.421-.947.946-.947zm-.722 3.038h1.444v10.041H6.647V7.416zm3.562 0h3.9c3.712 0 5.344 2.653 5.344 5.025 0 2.578-2.016 5.025-5.325 5.025h-3.919V7.416zm1.444 1.303v7.444h2.297c3.272 0 4.022-2.484 4.022-3.722 0-2.016-1.284-3.722-4.097-3.722h-2.222z"/>
|
|
156
|
+
</svg>
|
|
157
|
+
)}
|
|
158
|
+
|
|
159
|
+
{key === 'generic' && (
|
|
160
|
+
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
161
|
+
<circle cx="12" cy="12" r="10"/>
|
|
162
|
+
<line x1="2" y1="12" x2="22" y2="12"/>
|
|
163
|
+
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
|
|
164
|
+
</svg>
|
|
165
|
+
)}
|
|
166
|
+
</a>
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Breadcrumb helpers. The Astro `Breadcrumb` component consumes
|
|
3
|
+
* `BreadcrumbItem[]`; this module derives that array from a URL
|
|
4
|
+
* pathname plus a per-app label map.
|
|
5
|
+
*
|
|
6
|
+
* Labels: each app declares a `Record<string, string>` mapping URL
|
|
7
|
+
* segments (or full paths) to display names. The deriver walks the
|
|
8
|
+
* path one segment at a time, prefers a full-path hit over a
|
|
9
|
+
* segment hit, and falls back to a title-cased rendering of the
|
|
10
|
+
* segment itself. Segments without a humane label can be marked
|
|
11
|
+
* `null` to skip the segment entirely (useful when a routing
|
|
12
|
+
* segment is structural rather than user-facing, e.g. `/blog/tag/`
|
|
13
|
+
* where `tag` is a routing token).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export interface BreadcrumbItem {
|
|
17
|
+
/** Display label. */
|
|
18
|
+
name: string;
|
|
19
|
+
/** Absolute URL or site-relative path. Omit on the current page
|
|
20
|
+
* and the component renders it as `aria-current="page"`. */
|
|
21
|
+
url?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type BreadcrumbLabelMap = Record<string, string | null>;
|
|
25
|
+
|
|
26
|
+
/** Title-case fallback: "k12-digital-campus" → "K12 Digital Campus".
|
|
27
|
+
* Used when a segment isn't in the label map. */
|
|
28
|
+
function humanize(segment: string): string {
|
|
29
|
+
return segment
|
|
30
|
+
.replace(/[-_]+/g, " ")
|
|
31
|
+
.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Build a breadcrumb trail from a URL pathname.
|
|
36
|
+
*
|
|
37
|
+
* @param pathname e.g. `Astro.url.pathname` — `/education/k12-digital-campus`
|
|
38
|
+
* @param labels per-app label map. Keys can be full paths
|
|
39
|
+
* (`/education/k12-digital-campus`) or single
|
|
40
|
+
* segments (`education`). Full-path keys win.
|
|
41
|
+
* @param homeName the label for the root crumb. Pass the app's
|
|
42
|
+
* short name (e.g. "Nyuchi", "Bundu", "Mukoko").
|
|
43
|
+
* @param currentName optional label override for the final crumb
|
|
44
|
+
* (e.g. an article title that isn't representable
|
|
45
|
+
* in a static label map).
|
|
46
|
+
*/
|
|
47
|
+
export function deriveBreadcrumbs(
|
|
48
|
+
pathname: string,
|
|
49
|
+
labels: BreadcrumbLabelMap,
|
|
50
|
+
homeName: string,
|
|
51
|
+
currentName?: string,
|
|
52
|
+
): BreadcrumbItem[] {
|
|
53
|
+
const segments = pathname.split("/").filter(Boolean);
|
|
54
|
+
if (segments.length === 0) return [];
|
|
55
|
+
|
|
56
|
+
const items: BreadcrumbItem[] = [{ name: homeName, url: "/" }];
|
|
57
|
+
let cumulative = "";
|
|
58
|
+
|
|
59
|
+
for (let i = 0; i < segments.length; i++) {
|
|
60
|
+
const segment = segments[i];
|
|
61
|
+
cumulative += `/${segment}`;
|
|
62
|
+
const isLast = i === segments.length - 1;
|
|
63
|
+
|
|
64
|
+
// Full-path label wins over single-segment.
|
|
65
|
+
const explicit = Object.prototype.hasOwnProperty.call(labels, cumulative)
|
|
66
|
+
? labels[cumulative]
|
|
67
|
+
: Object.prototype.hasOwnProperty.call(labels, segment)
|
|
68
|
+
? labels[segment]
|
|
69
|
+
: undefined;
|
|
70
|
+
|
|
71
|
+
// `null` in the label map means "skip this segment entirely"
|
|
72
|
+
// (used for routing tokens like `/blog/tag/` where `tag` adds
|
|
73
|
+
// no breadcrumb value).
|
|
74
|
+
if (explicit === null) continue;
|
|
75
|
+
|
|
76
|
+
const name =
|
|
77
|
+
isLast && currentName ? currentName : (explicit ?? humanize(segment));
|
|
78
|
+
|
|
79
|
+
items.push(isLast ? { name } : { name, url: cumulative });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return items;
|
|
83
|
+
}
|
package/src/index.ts
ADDED
package/src/lib/utils.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { clsx, type ClassValue } from "clsx";
|
|
2
|
+
import { extendTailwindMerge } from "tailwind-merge";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Tailwind-merge configured for the Nyuchi Design System's custom type
|
|
6
|
+
* scale. Without this, merge treats the named font sizes (`text-body`,
|
|
7
|
+
* `text-h2`, …) as *colours* and drops a legitimately co-existing
|
|
8
|
+
* `text-primary-foreground`. Declaring them in the `font-size` group
|
|
9
|
+
* keeps colour + size independent, the way raw Tailwind generates them.
|
|
10
|
+
*/
|
|
11
|
+
const twMerge = extendTailwindMerge({
|
|
12
|
+
extend: {
|
|
13
|
+
classGroups: {
|
|
14
|
+
"font-size": [
|
|
15
|
+
{
|
|
16
|
+
text: [
|
|
17
|
+
"display",
|
|
18
|
+
"display-sm",
|
|
19
|
+
"h1",
|
|
20
|
+
"h2",
|
|
21
|
+
"h3",
|
|
22
|
+
"h4",
|
|
23
|
+
"h5",
|
|
24
|
+
"h6",
|
|
25
|
+
"body-lg",
|
|
26
|
+
"body",
|
|
27
|
+
"body-sm",
|
|
28
|
+
"caption",
|
|
29
|
+
],
|
|
30
|
+
},
|
|
31
|
+
],
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* `cn` — the shadcn class-merge helper the Nyuchi Design System is built
|
|
38
|
+
* on. Merges conditional class lists (clsx) and resolves Tailwind
|
|
39
|
+
* conflicts last-wins (tailwind-merge). Every UI component composes its
|
|
40
|
+
* variant classes through this.
|
|
41
|
+
*/
|
|
42
|
+
export function cn(...inputs: ClassValue[]) {
|
|
43
|
+
return twMerge(clsx(inputs));
|
|
44
|
+
}
|
package/src/ui/alert.tsx
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
3
|
+
|
|
4
|
+
import { cn } from "../lib/utils";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Alert — shadcn CVA pattern mapped onto the Five-African-Minerals
|
|
8
|
+
* container tokens. Variants name the semantic role, not the colour.
|
|
9
|
+
* `role="alert"` announces the message to assistive tech.
|
|
10
|
+
*/
|
|
11
|
+
export const alertVariants = cva(
|
|
12
|
+
"relative w-full rounded-lg border px-4 py-3 text-body-sm",
|
|
13
|
+
{
|
|
14
|
+
variants: {
|
|
15
|
+
variant: {
|
|
16
|
+
default: "bg-card text-card-foreground border-border",
|
|
17
|
+
info: "bg-cobalt-container text-cobalt-on-container border-transparent",
|
|
18
|
+
success:
|
|
19
|
+
"bg-malachite-container text-malachite-on-container border-transparent",
|
|
20
|
+
warning:
|
|
21
|
+
"bg-gold-container text-gold-on-container border-transparent",
|
|
22
|
+
destructive: "bg-card text-destructive border-destructive/40",
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
defaultVariants: {
|
|
26
|
+
variant: "default",
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
export interface AlertProps
|
|
32
|
+
extends React.HTMLAttributes<HTMLDivElement>,
|
|
33
|
+
VariantProps<typeof alertVariants> {
|
|
34
|
+
class?: string;
|
|
35
|
+
className?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function Alert({
|
|
39
|
+
variant,
|
|
40
|
+
class: astroClass,
|
|
41
|
+
className,
|
|
42
|
+
children,
|
|
43
|
+
...props
|
|
44
|
+
}: AlertProps) {
|
|
45
|
+
return (
|
|
46
|
+
<div
|
|
47
|
+
role="alert"
|
|
48
|
+
data-slot="alert"
|
|
49
|
+
className={cn(alertVariants({ variant }), astroClass, className)}
|
|
50
|
+
{...props}
|
|
51
|
+
>
|
|
52
|
+
{children}
|
|
53
|
+
</div>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface AlertTitleProps
|
|
58
|
+
extends React.HTMLAttributes<HTMLParagraphElement> {
|
|
59
|
+
class?: string;
|
|
60
|
+
className?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function AlertTitle({
|
|
64
|
+
class: astroClass,
|
|
65
|
+
className,
|
|
66
|
+
children,
|
|
67
|
+
...props
|
|
68
|
+
}: AlertTitleProps) {
|
|
69
|
+
return (
|
|
70
|
+
<p
|
|
71
|
+
data-slot="alert-title"
|
|
72
|
+
className={cn("mb-1 font-medium leading-none", astroClass, className)}
|
|
73
|
+
{...props}
|
|
74
|
+
>
|
|
75
|
+
{children}
|
|
76
|
+
</p>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function AlertDescription({
|
|
81
|
+
class: astroClass,
|
|
82
|
+
className,
|
|
83
|
+
children,
|
|
84
|
+
...props
|
|
85
|
+
}: AlertTitleProps) {
|
|
86
|
+
return (
|
|
87
|
+
<div
|
|
88
|
+
data-slot="alert-description"
|
|
89
|
+
className={cn(
|
|
90
|
+
"text-body-sm [&_p]:leading-relaxed opacity-90",
|
|
91
|
+
astroClass,
|
|
92
|
+
className,
|
|
93
|
+
)}
|
|
94
|
+
{...props}
|
|
95
|
+
>
|
|
96
|
+
{children}
|
|
97
|
+
</div>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
3
|
+
|
|
4
|
+
import { cn } from "../lib/utils";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Avatar — dependency-light image avatar with an initials fallback.
|
|
8
|
+
*
|
|
9
|
+
* NOT Radix: kept minimal and SSR-friendly. Renders the image when
|
|
10
|
+
* `src` is set (falling back to initials on load error via a tiny
|
|
11
|
+
* inline handler), otherwise renders the `fallback` initials. Colours
|
|
12
|
+
* come from the semantic `muted` surface token — no hex.
|
|
13
|
+
*/
|
|
14
|
+
export const avatarVariants = cva(
|
|
15
|
+
"relative inline-flex shrink-0 items-center justify-center overflow-hidden rounded-full bg-muted text-muted-foreground font-medium select-none",
|
|
16
|
+
{
|
|
17
|
+
variants: {
|
|
18
|
+
size: {
|
|
19
|
+
sm: "h-8 w-8 text-caption",
|
|
20
|
+
md: "h-10 w-10 text-body-sm",
|
|
21
|
+
lg: "h-14 w-14 text-body",
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
defaultVariants: {
|
|
25
|
+
size: "md",
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
export interface AvatarProps extends VariantProps<typeof avatarVariants> {
|
|
31
|
+
/** Image URL. When omitted, the initials fallback renders. */
|
|
32
|
+
src?: string;
|
|
33
|
+
/** Alt text for the image / accessible label. */
|
|
34
|
+
alt?: string;
|
|
35
|
+
/** Initials (or short text) shown when there's no image. */
|
|
36
|
+
fallback?: string;
|
|
37
|
+
class?: string;
|
|
38
|
+
className?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function Avatar({
|
|
42
|
+
size,
|
|
43
|
+
src,
|
|
44
|
+
alt,
|
|
45
|
+
fallback,
|
|
46
|
+
class: astroClass,
|
|
47
|
+
className,
|
|
48
|
+
}: AvatarProps) {
|
|
49
|
+
return (
|
|
50
|
+
<span
|
|
51
|
+
data-slot="avatar"
|
|
52
|
+
role="img"
|
|
53
|
+
aria-label={alt ?? fallback}
|
|
54
|
+
className={cn(avatarVariants({ size }), astroClass, className)}
|
|
55
|
+
>
|
|
56
|
+
{src ? (
|
|
57
|
+
<img
|
|
58
|
+
src={src}
|
|
59
|
+
alt={alt ?? ""}
|
|
60
|
+
className="h-full w-full object-cover"
|
|
61
|
+
loading="lazy"
|
|
62
|
+
data-slot="avatar-image"
|
|
63
|
+
/>
|
|
64
|
+
) : (
|
|
65
|
+
<span data-slot="avatar-fallback">{fallback}</span>
|
|
66
|
+
)}
|
|
67
|
+
</span>
|
|
68
|
+
);
|
|
69
|
+
}
|
package/src/ui/badge.tsx
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
3
|
+
|
|
4
|
+
import { cn } from "../lib/utils";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Badge — shadcn CVA pattern mapped onto the Five-African-Minerals
|
|
8
|
+
* container tokens. Variants name the semantic role, not the colour, so
|
|
9
|
+
* a brand re-skin lands everywhere at once.
|
|
10
|
+
*/
|
|
11
|
+
export const badgeVariants = cva(
|
|
12
|
+
"inline-flex items-center rounded-full font-medium",
|
|
13
|
+
{
|
|
14
|
+
variants: {
|
|
15
|
+
variant: {
|
|
16
|
+
default: "bg-muted text-muted-foreground",
|
|
17
|
+
primary: "bg-cobalt-container text-cobalt-on-container",
|
|
18
|
+
success: "bg-malachite-container text-malachite-on-container",
|
|
19
|
+
warning: "bg-gold-container text-gold-on-container",
|
|
20
|
+
info: "bg-cobalt-container text-cobalt-on-container",
|
|
21
|
+
accent: "bg-terracotta-container text-terracotta-on-container",
|
|
22
|
+
premium: "bg-tanzanite-container text-tanzanite-on-container",
|
|
23
|
+
outline: "border border-border text-foreground",
|
|
24
|
+
},
|
|
25
|
+
size: {
|
|
26
|
+
sm: "px-2 py-0.5 text-caption",
|
|
27
|
+
md: "px-3 py-1 text-body-sm",
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
defaultVariants: {
|
|
31
|
+
variant: "default",
|
|
32
|
+
size: "md",
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
export interface BadgeProps extends VariantProps<typeof badgeVariants> {
|
|
38
|
+
class?: string;
|
|
39
|
+
className?: string;
|
|
40
|
+
children?: React.ReactNode;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function Badge({
|
|
44
|
+
variant,
|
|
45
|
+
size,
|
|
46
|
+
class: astroClass,
|
|
47
|
+
className,
|
|
48
|
+
children,
|
|
49
|
+
}: BadgeProps) {
|
|
50
|
+
return (
|
|
51
|
+
<span
|
|
52
|
+
className={cn(badgeVariants({ variant, size }), astroClass, className)}
|
|
53
|
+
data-slot="badge"
|
|
54
|
+
>
|
|
55
|
+
{children}
|
|
56
|
+
</span>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
3
|
+
|
|
4
|
+
import { cn } from "../lib/utils";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Button — shadcn CVA pattern (the source the Nyuchi Design System is
|
|
8
|
+
* built on), mapped onto the Five-African-Minerals semantic tokens.
|
|
9
|
+
*
|
|
10
|
+
* Astro adaptation: renders an <a> when `href` is set, otherwise a
|
|
11
|
+
* <button>. Touch targets follow the Ubuntu checklist — 56px (h-14)
|
|
12
|
+
* for the large size, 48px (h-12) minimum — for outdoor, all-ages use.
|
|
13
|
+
*/
|
|
14
|
+
export const buttonVariants = cva(
|
|
15
|
+
"inline-flex items-center justify-center gap-2 font-medium rounded-full transition-all duration-200 ease-soft outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:opacity-50 disabled:cursor-not-allowed",
|
|
16
|
+
{
|
|
17
|
+
variants: {
|
|
18
|
+
variant: {
|
|
19
|
+
primary: "bg-primary text-primary-foreground hover:opacity-90",
|
|
20
|
+
secondary: "bg-foreground text-background hover:opacity-90",
|
|
21
|
+
outline:
|
|
22
|
+
"border border-foreground text-foreground hover:bg-foreground hover:text-background",
|
|
23
|
+
ghost: "text-foreground hover:bg-muted",
|
|
24
|
+
},
|
|
25
|
+
size: {
|
|
26
|
+
sm: "h-12 px-4 text-body-sm",
|
|
27
|
+
md: "h-12 px-6 text-body",
|
|
28
|
+
lg: "h-14 px-8 text-body-lg",
|
|
29
|
+
},
|
|
30
|
+
fullWidth: {
|
|
31
|
+
true: "w-full",
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
defaultVariants: {
|
|
35
|
+
variant: "primary",
|
|
36
|
+
size: "md",
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
const ArrowRight = () => (
|
|
42
|
+
<svg
|
|
43
|
+
className="h-4 w-4"
|
|
44
|
+
fill="none"
|
|
45
|
+
stroke="currentColor"
|
|
46
|
+
viewBox="0 0 24 24"
|
|
47
|
+
aria-hidden="true"
|
|
48
|
+
>
|
|
49
|
+
<path
|
|
50
|
+
strokeLinecap="round"
|
|
51
|
+
strokeLinejoin="round"
|
|
52
|
+
strokeWidth={1.75}
|
|
53
|
+
d="M17 8l4 4m0 0l-4 4m4-4H3"
|
|
54
|
+
/>
|
|
55
|
+
</svg>
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
export interface ButtonProps extends VariantProps<typeof buttonVariants> {
|
|
59
|
+
/** Render as a link when set. */
|
|
60
|
+
href?: string;
|
|
61
|
+
/** Opens in a new tab with safe rel. */
|
|
62
|
+
external?: boolean;
|
|
63
|
+
/** Append a trailing arrow glyph (keeps the SVG out of pages). */
|
|
64
|
+
arrow?: boolean;
|
|
65
|
+
type?: "button" | "submit" | "reset";
|
|
66
|
+
disabled?: boolean;
|
|
67
|
+
class?: string;
|
|
68
|
+
className?: string;
|
|
69
|
+
children?: React.ReactNode;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function Button({
|
|
73
|
+
variant,
|
|
74
|
+
size,
|
|
75
|
+
fullWidth,
|
|
76
|
+
href,
|
|
77
|
+
external,
|
|
78
|
+
arrow,
|
|
79
|
+
type = "button",
|
|
80
|
+
disabled,
|
|
81
|
+
class: astroClass,
|
|
82
|
+
className,
|
|
83
|
+
children,
|
|
84
|
+
}: ButtonProps) {
|
|
85
|
+
const classes = cn(
|
|
86
|
+
buttonVariants({ variant, size, fullWidth }),
|
|
87
|
+
astroClass,
|
|
88
|
+
className,
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
if (href) {
|
|
92
|
+
const ext = external
|
|
93
|
+
? { target: "_blank", rel: "noopener noreferrer" }
|
|
94
|
+
: {};
|
|
95
|
+
return (
|
|
96
|
+
<a href={href} className={classes} data-slot="button" {...ext}>
|
|
97
|
+
{children}
|
|
98
|
+
{arrow && <ArrowRight />}
|
|
99
|
+
</a>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<button
|
|
105
|
+
type={type}
|
|
106
|
+
disabled={disabled}
|
|
107
|
+
className={classes}
|
|
108
|
+
data-slot="button"
|
|
109
|
+
>
|
|
110
|
+
{children}
|
|
111
|
+
{arrow && <ArrowRight />}
|
|
112
|
+
</button>
|
|
113
|
+
);
|
|
114
|
+
}
|