@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
package/src/ui/card.tsx
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
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
|
+
* Card — shadcn CVA pattern over the design system's `.card` surface
|
|
8
|
+
* token (defined in each app's global.css @layer components). Renders an
|
|
9
|
+
* <a> when `href` is set so whole-card links stay a single component.
|
|
10
|
+
*/
|
|
11
|
+
export const cardVariants = cva("card", {
|
|
12
|
+
variants: {
|
|
13
|
+
padding: {
|
|
14
|
+
none: "",
|
|
15
|
+
sm: "p-4",
|
|
16
|
+
md: "p-6",
|
|
17
|
+
lg: "p-8",
|
|
18
|
+
},
|
|
19
|
+
hover: {
|
|
20
|
+
true: "card-hover",
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
defaultVariants: {
|
|
24
|
+
padding: "md",
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
export interface CardProps extends VariantProps<typeof cardVariants> {
|
|
29
|
+
href?: string;
|
|
30
|
+
external?: boolean;
|
|
31
|
+
class?: string;
|
|
32
|
+
className?: string;
|
|
33
|
+
children?: React.ReactNode;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function Card({
|
|
37
|
+
padding,
|
|
38
|
+
hover,
|
|
39
|
+
href,
|
|
40
|
+
external,
|
|
41
|
+
class: astroClass,
|
|
42
|
+
className,
|
|
43
|
+
children,
|
|
44
|
+
}: CardProps) {
|
|
45
|
+
const classes = cn(cardVariants({ padding, hover }), astroClass, className);
|
|
46
|
+
|
|
47
|
+
if (href) {
|
|
48
|
+
const ext = external
|
|
49
|
+
? { target: "_blank", rel: "noopener noreferrer" }
|
|
50
|
+
: {};
|
|
51
|
+
return (
|
|
52
|
+
<a href={href} className={classes} data-slot="card" {...ext}>
|
|
53
|
+
{children}
|
|
54
|
+
</a>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<div className={classes} data-slot="card">
|
|
60
|
+
{children}
|
|
61
|
+
</div>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
|
|
3
|
+
import { cn } from "../lib/utils";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Checkbox — a NATIVE `<input type="checkbox">` styled to the design
|
|
7
|
+
* system's semantic tokens.
|
|
8
|
+
*
|
|
9
|
+
* Deliberately NOT Radix: this is the minimal accessible, form-native,
|
|
10
|
+
* SSR-friendly implementation. It uses the CSS `accent-color`
|
|
11
|
+
* (`accent-primary`) so the browser's built-in checked glyph is tinted
|
|
12
|
+
* to the brand `--primary` token — no client JS, fully keyboard- and
|
|
13
|
+
* screen-reader-accessible, and it submits inside a `<form>` like any
|
|
14
|
+
* native checkbox. The 20px box sits inside a comfortable label hit
|
|
15
|
+
* area; pair it with a `<Label>` for a larger touch target.
|
|
16
|
+
*/
|
|
17
|
+
export interface CheckboxProps
|
|
18
|
+
extends React.InputHTMLAttributes<HTMLInputElement> {
|
|
19
|
+
class?: string;
|
|
20
|
+
className?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function Checkbox({
|
|
24
|
+
class: astroClass,
|
|
25
|
+
className,
|
|
26
|
+
...props
|
|
27
|
+
}: CheckboxProps) {
|
|
28
|
+
return (
|
|
29
|
+
<input
|
|
30
|
+
type="checkbox"
|
|
31
|
+
data-slot="checkbox"
|
|
32
|
+
className={cn(
|
|
33
|
+
"h-5 w-5 shrink-0 cursor-pointer rounded-[7px] border border-border bg-background accent-primary transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50",
|
|
34
|
+
astroClass,
|
|
35
|
+
className,
|
|
36
|
+
)}
|
|
37
|
+
{...props}
|
|
38
|
+
/>
|
|
39
|
+
);
|
|
40
|
+
}
|
package/src/ui/input.tsx
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
|
|
3
|
+
import { cn } from "../lib/utils";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Input — native <input> styled to the Nyuchi Design System tokens
|
|
7
|
+
* (shadcn pattern, mapped onto the Five-African-Minerals semantic
|
|
8
|
+
* tokens). Dependency-free and SSR-friendly so it renders as plain HTML
|
|
9
|
+
* inside Astro without a client directive.
|
|
10
|
+
*
|
|
11
|
+
* Forwards every standard input prop (type, name, id, placeholder,
|
|
12
|
+
* required, value, defaultValue, autocomplete, inputmode, …) via
|
|
13
|
+
* `...props`. The `h-12` floor keeps the Ubuntu 48px minimum touch
|
|
14
|
+
* target for outdoor, all-ages use.
|
|
15
|
+
*/
|
|
16
|
+
export const inputClasses =
|
|
17
|
+
"flex h-12 w-full rounded-lg border border-border bg-background px-4 text-body text-foreground transition-colors placeholder:text-muted-foreground 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";
|
|
18
|
+
|
|
19
|
+
export interface InputProps
|
|
20
|
+
extends React.InputHTMLAttributes<HTMLInputElement> {
|
|
21
|
+
/** Astro-style class attribute (merged with `className`). */
|
|
22
|
+
class?: string;
|
|
23
|
+
className?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function Input({ class: astroClass, className, ...props }: InputProps) {
|
|
27
|
+
return (
|
|
28
|
+
<input
|
|
29
|
+
data-slot="input"
|
|
30
|
+
className={cn(inputClasses, astroClass, className)}
|
|
31
|
+
{...props}
|
|
32
|
+
/>
|
|
33
|
+
);
|
|
34
|
+
}
|
package/src/ui/label.tsx
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
|
|
3
|
+
import { cn } from "../lib/utils";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Label — native <label> styled to the Nyuchi Design System tokens
|
|
7
|
+
* (shadcn pattern over the Five-African-Minerals tokens).
|
|
8
|
+
* Dependency-free and SSR-friendly. Forwards every standard label prop
|
|
9
|
+
* (htmlFor / for, id, …) via `...props` and renders its children as-is.
|
|
10
|
+
*/
|
|
11
|
+
export const labelClasses = "text-body-sm font-medium text-foreground";
|
|
12
|
+
|
|
13
|
+
export interface LabelProps
|
|
14
|
+
extends React.LabelHTMLAttributes<HTMLLabelElement> {
|
|
15
|
+
/** Astro-style class attribute (merged with `className`). */
|
|
16
|
+
class?: string;
|
|
17
|
+
className?: string;
|
|
18
|
+
/** Astro-style `for` attribute (maps to React `htmlFor`). */
|
|
19
|
+
for?: string;
|
|
20
|
+
children?: React.ReactNode;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function Label({
|
|
24
|
+
class: astroClass,
|
|
25
|
+
className,
|
|
26
|
+
for: astroFor,
|
|
27
|
+
htmlFor,
|
|
28
|
+
children,
|
|
29
|
+
...props
|
|
30
|
+
}: LabelProps) {
|
|
31
|
+
return (
|
|
32
|
+
<label
|
|
33
|
+
data-slot="label"
|
|
34
|
+
htmlFor={htmlFor ?? astroFor}
|
|
35
|
+
className={cn(labelClasses, astroClass, className)}
|
|
36
|
+
{...props}
|
|
37
|
+
>
|
|
38
|
+
{children}
|
|
39
|
+
</label>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
|
|
3
|
+
import { cn } from "../lib/utils";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Select — native <select> styled to the Nyuchi Design System tokens
|
|
7
|
+
* (shadcn pattern over the Five-African-Minerals tokens). Deliberately
|
|
8
|
+
* NOT Radix: kept dependency-free and SSR-friendly so it renders as
|
|
9
|
+
* plain HTML inside Astro without a client directive.
|
|
10
|
+
*
|
|
11
|
+
* Forwards every standard select prop (name, id, required, value,
|
|
12
|
+
* defaultValue, …) via `...props`, and renders its <option> children
|
|
13
|
+
* as-is.
|
|
14
|
+
*/
|
|
15
|
+
export const selectClasses =
|
|
16
|
+
"flex h-12 w-full appearance-none rounded-lg border border-border bg-background px-4 text-body text-foreground transition-colors outline-none cursor-pointer 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";
|
|
17
|
+
|
|
18
|
+
export interface SelectProps
|
|
19
|
+
extends React.SelectHTMLAttributes<HTMLSelectElement> {
|
|
20
|
+
/** Astro-style class attribute (merged with `className`). */
|
|
21
|
+
class?: string;
|
|
22
|
+
className?: string;
|
|
23
|
+
children?: React.ReactNode;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function Select({
|
|
27
|
+
class: astroClass,
|
|
28
|
+
className,
|
|
29
|
+
children,
|
|
30
|
+
...props
|
|
31
|
+
}: SelectProps) {
|
|
32
|
+
return (
|
|
33
|
+
<select
|
|
34
|
+
data-slot="select"
|
|
35
|
+
className={cn(selectClasses, astroClass, className)}
|
|
36
|
+
{...props}
|
|
37
|
+
>
|
|
38
|
+
{children}
|
|
39
|
+
</select>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
|
|
3
|
+
import { cn } from "../lib/utils";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Separator — a thin rule using the semantic `border` token.
|
|
7
|
+
*
|
|
8
|
+
* NOT Radix: a plain, accessible `<div>` with the correct ARIA. When
|
|
9
|
+
* `decorative` (the default) it's `role="none"` and hidden from the
|
|
10
|
+
* a11y tree; set `decorative={false}` for a semantic separator that
|
|
11
|
+
* exposes `role="separator"` + `aria-orientation`.
|
|
12
|
+
*/
|
|
13
|
+
export interface SeparatorProps
|
|
14
|
+
extends React.HTMLAttributes<HTMLDivElement> {
|
|
15
|
+
orientation?: "horizontal" | "vertical";
|
|
16
|
+
decorative?: boolean;
|
|
17
|
+
class?: string;
|
|
18
|
+
className?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function Separator({
|
|
22
|
+
orientation = "horizontal",
|
|
23
|
+
decorative = true,
|
|
24
|
+
class: astroClass,
|
|
25
|
+
className,
|
|
26
|
+
...props
|
|
27
|
+
}: SeparatorProps) {
|
|
28
|
+
return (
|
|
29
|
+
<div
|
|
30
|
+
data-slot="separator"
|
|
31
|
+
data-orientation={orientation}
|
|
32
|
+
role={decorative ? "none" : "separator"}
|
|
33
|
+
aria-orientation={
|
|
34
|
+
decorative ? undefined : orientation === "vertical" ? "vertical" : "horizontal"
|
|
35
|
+
}
|
|
36
|
+
className={cn(
|
|
37
|
+
"shrink-0 bg-border",
|
|
38
|
+
orientation === "vertical" ? "h-full w-px" : "h-px w-full",
|
|
39
|
+
astroClass,
|
|
40
|
+
className,
|
|
41
|
+
)}
|
|
42
|
+
{...props}
|
|
43
|
+
/>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
|
|
3
|
+
import { cn } from "../lib/utils";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Skeleton — a loading placeholder using the semantic `muted` token
|
|
7
|
+
* and Tailwind's `animate-pulse`. Dependency-free and SSR-friendly.
|
|
8
|
+
* Size it with utility classes (`h-4 w-32`, `h-10 w-10 rounded-full`,
|
|
9
|
+
* …) via `class` / `className`.
|
|
10
|
+
*/
|
|
11
|
+
export interface SkeletonProps
|
|
12
|
+
extends React.HTMLAttributes<HTMLDivElement> {
|
|
13
|
+
class?: string;
|
|
14
|
+
className?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function Skeleton({
|
|
18
|
+
class: astroClass,
|
|
19
|
+
className,
|
|
20
|
+
...props
|
|
21
|
+
}: SkeletonProps) {
|
|
22
|
+
return (
|
|
23
|
+
<div
|
|
24
|
+
data-slot="skeleton"
|
|
25
|
+
aria-hidden="true"
|
|
26
|
+
className={cn("animate-pulse rounded-md bg-muted", astroClass, className)}
|
|
27
|
+
{...props}
|
|
28
|
+
/>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
|
|
5
|
+
import { cn } from "../lib/utils";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Switch — a minimal accessible toggle.
|
|
9
|
+
*
|
|
10
|
+
* Deliberately NOT Radix (kept dependency-light): a `<button>` with
|
|
11
|
+
* `role="switch"` and `aria-checked`, supporting both controlled
|
|
12
|
+
* (`checked` + `onCheckedChange`) and uncontrolled (`defaultChecked`)
|
|
13
|
+
* use. It expands its hit area to the Ubuntu 48px touch-target floor
|
|
14
|
+
* with `p-3 -m-3` (padding out, negative margin back) so the visual
|
|
15
|
+
* track stays compact while the tap target stays large. Colours come
|
|
16
|
+
* from the semantic `primary` / `muted` tokens — no hex.
|
|
17
|
+
*/
|
|
18
|
+
export interface SwitchProps {
|
|
19
|
+
checked?: boolean;
|
|
20
|
+
defaultChecked?: boolean;
|
|
21
|
+
onCheckedChange?: (checked: boolean) => void;
|
|
22
|
+
disabled?: boolean;
|
|
23
|
+
id?: string;
|
|
24
|
+
name?: string;
|
|
25
|
+
value?: string;
|
|
26
|
+
"aria-label"?: string;
|
|
27
|
+
"aria-labelledby"?: string;
|
|
28
|
+
class?: string;
|
|
29
|
+
className?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function Switch({
|
|
33
|
+
checked,
|
|
34
|
+
defaultChecked,
|
|
35
|
+
onCheckedChange,
|
|
36
|
+
disabled,
|
|
37
|
+
id,
|
|
38
|
+
name,
|
|
39
|
+
value,
|
|
40
|
+
class: astroClass,
|
|
41
|
+
className,
|
|
42
|
+
...aria
|
|
43
|
+
}: SwitchProps) {
|
|
44
|
+
const isControlled = checked !== undefined;
|
|
45
|
+
const [internal, setInternal] = React.useState(defaultChecked ?? false);
|
|
46
|
+
const isOn = isControlled ? checked : internal;
|
|
47
|
+
|
|
48
|
+
function toggle() {
|
|
49
|
+
if (disabled) return;
|
|
50
|
+
const next = !isOn;
|
|
51
|
+
if (!isControlled) setInternal(next);
|
|
52
|
+
onCheckedChange?.(next);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<button
|
|
57
|
+
type="button"
|
|
58
|
+
role="switch"
|
|
59
|
+
id={id}
|
|
60
|
+
aria-checked={isOn}
|
|
61
|
+
aria-label={aria["aria-label"]}
|
|
62
|
+
aria-labelledby={aria["aria-labelledby"]}
|
|
63
|
+
disabled={disabled}
|
|
64
|
+
data-slot="switch"
|
|
65
|
+
data-state={isOn ? "checked" : "unchecked"}
|
|
66
|
+
onClick={toggle}
|
|
67
|
+
className={cn(
|
|
68
|
+
"group relative box-content inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full p-3 -m-3 outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50",
|
|
69
|
+
astroClass,
|
|
70
|
+
className,
|
|
71
|
+
)}
|
|
72
|
+
>
|
|
73
|
+
<span
|
|
74
|
+
aria-hidden="true"
|
|
75
|
+
className={cn(
|
|
76
|
+
"pointer-events-none inline-flex h-6 w-11 items-center rounded-full transition-colors",
|
|
77
|
+
isOn ? "bg-primary" : "bg-muted",
|
|
78
|
+
)}
|
|
79
|
+
>
|
|
80
|
+
<span
|
|
81
|
+
className={cn(
|
|
82
|
+
"h-5 w-5 rounded-full bg-background shadow-sm transition-transform",
|
|
83
|
+
isOn ? "translate-x-[22px]" : "translate-x-0.5",
|
|
84
|
+
)}
|
|
85
|
+
/>
|
|
86
|
+
</span>
|
|
87
|
+
{name ? (
|
|
88
|
+
<input
|
|
89
|
+
type="checkbox"
|
|
90
|
+
name={name}
|
|
91
|
+
value={value}
|
|
92
|
+
checked={isOn}
|
|
93
|
+
readOnly
|
|
94
|
+
hidden
|
|
95
|
+
aria-hidden="true"
|
|
96
|
+
tabIndex={-1}
|
|
97
|
+
/>
|
|
98
|
+
) : null}
|
|
99
|
+
</button>
|
|
100
|
+
);
|
|
101
|
+
}
|
package/src/ui/tabs.tsx
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
|
|
5
|
+
import { cn } from "../lib/utils";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Tabs — a minimal accessible tab set.
|
|
9
|
+
*
|
|
10
|
+
* Deliberately NOT Radix (kept dependency-light): a small React
|
|
11
|
+
* context drives selection, with the WAI-ARIA tabs pattern —
|
|
12
|
+
* `role="tablist" / "tab" / "tabpanel"`, `aria-selected`,
|
|
13
|
+
* `aria-controls`/`aria-labelledby`, roving `tabIndex`, and
|
|
14
|
+
* Left/Right/Home/End keyboard navigation. Controlled (`value` +
|
|
15
|
+
* `onValueChange`) or uncontrolled (`defaultValue`). Colours come from
|
|
16
|
+
* the semantic tokens — no hex.
|
|
17
|
+
*/
|
|
18
|
+
interface TabsContextValue {
|
|
19
|
+
value: string;
|
|
20
|
+
setValue: (v: string) => void;
|
|
21
|
+
baseId: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const TabsContext = React.createContext<TabsContextValue | null>(null);
|
|
25
|
+
|
|
26
|
+
function useTabs(component: string): TabsContextValue {
|
|
27
|
+
const ctx = React.useContext(TabsContext);
|
|
28
|
+
if (!ctx) throw new Error(`<${component}> must be used within <Tabs>`);
|
|
29
|
+
return ctx;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface TabsProps {
|
|
33
|
+
value?: string;
|
|
34
|
+
defaultValue?: string;
|
|
35
|
+
onValueChange?: (value: string) => void;
|
|
36
|
+
id?: string;
|
|
37
|
+
class?: string;
|
|
38
|
+
className?: string;
|
|
39
|
+
children?: React.ReactNode;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function Tabs({
|
|
43
|
+
value,
|
|
44
|
+
defaultValue,
|
|
45
|
+
onValueChange,
|
|
46
|
+
id,
|
|
47
|
+
class: astroClass,
|
|
48
|
+
className,
|
|
49
|
+
children,
|
|
50
|
+
}: TabsProps) {
|
|
51
|
+
const isControlled = value !== undefined;
|
|
52
|
+
const [internal, setInternal] = React.useState(defaultValue ?? "");
|
|
53
|
+
const current = isControlled ? value : internal;
|
|
54
|
+
const reactId = React.useId();
|
|
55
|
+
const baseId = id ?? reactId;
|
|
56
|
+
|
|
57
|
+
const setValue = React.useCallback(
|
|
58
|
+
(v: string) => {
|
|
59
|
+
if (!isControlled) setInternal(v);
|
|
60
|
+
onValueChange?.(v);
|
|
61
|
+
},
|
|
62
|
+
[isControlled, onValueChange],
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<TabsContext.Provider value={{ value: current, setValue, baseId }}>
|
|
67
|
+
<div data-slot="tabs" className={cn(astroClass, className)}>
|
|
68
|
+
{children}
|
|
69
|
+
</div>
|
|
70
|
+
</TabsContext.Provider>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface TabsListProps
|
|
75
|
+
extends React.HTMLAttributes<HTMLDivElement> {
|
|
76
|
+
class?: string;
|
|
77
|
+
className?: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function TabsList({
|
|
81
|
+
class: astroClass,
|
|
82
|
+
className,
|
|
83
|
+
children,
|
|
84
|
+
...props
|
|
85
|
+
}: TabsListProps) {
|
|
86
|
+
function onKeyDown(e: React.KeyboardEvent<HTMLDivElement>) {
|
|
87
|
+
const keys = ["ArrowRight", "ArrowLeft", "Home", "End"];
|
|
88
|
+
if (!keys.includes(e.key)) return;
|
|
89
|
+
const tabs = Array.from(
|
|
90
|
+
e.currentTarget.querySelectorAll<HTMLButtonElement>(
|
|
91
|
+
'[role="tab"]:not([disabled])',
|
|
92
|
+
),
|
|
93
|
+
);
|
|
94
|
+
const idx = tabs.indexOf(document.activeElement as HTMLButtonElement);
|
|
95
|
+
if (idx === -1) return;
|
|
96
|
+
e.preventDefault();
|
|
97
|
+
let next = idx;
|
|
98
|
+
if (e.key === "ArrowRight") next = (idx + 1) % tabs.length;
|
|
99
|
+
else if (e.key === "ArrowLeft") next = (idx - 1 + tabs.length) % tabs.length;
|
|
100
|
+
else if (e.key === "Home") next = 0;
|
|
101
|
+
else if (e.key === "End") next = tabs.length - 1;
|
|
102
|
+
tabs[next]?.focus();
|
|
103
|
+
tabs[next]?.click();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<div
|
|
108
|
+
role="tablist"
|
|
109
|
+
data-slot="tabs-list"
|
|
110
|
+
onKeyDown={onKeyDown}
|
|
111
|
+
className={cn(
|
|
112
|
+
"inline-flex items-center gap-1 rounded-full bg-muted p-1",
|
|
113
|
+
astroClass,
|
|
114
|
+
className,
|
|
115
|
+
)}
|
|
116
|
+
{...props}
|
|
117
|
+
>
|
|
118
|
+
{children}
|
|
119
|
+
</div>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface TabsTriggerProps {
|
|
124
|
+
value: string;
|
|
125
|
+
disabled?: boolean;
|
|
126
|
+
class?: string;
|
|
127
|
+
className?: string;
|
|
128
|
+
children?: React.ReactNode;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function TabsTrigger({
|
|
132
|
+
value,
|
|
133
|
+
disabled,
|
|
134
|
+
class: astroClass,
|
|
135
|
+
className,
|
|
136
|
+
children,
|
|
137
|
+
}: TabsTriggerProps) {
|
|
138
|
+
const { value: current, setValue, baseId } = useTabs("TabsTrigger");
|
|
139
|
+
const selected = current === value;
|
|
140
|
+
return (
|
|
141
|
+
<button
|
|
142
|
+
type="button"
|
|
143
|
+
role="tab"
|
|
144
|
+
id={`${baseId}-trigger-${value}`}
|
|
145
|
+
aria-selected={selected}
|
|
146
|
+
aria-controls={`${baseId}-content-${value}`}
|
|
147
|
+
tabIndex={selected ? 0 : -1}
|
|
148
|
+
disabled={disabled}
|
|
149
|
+
data-slot="tabs-trigger"
|
|
150
|
+
data-state={selected ? "active" : "inactive"}
|
|
151
|
+
onClick={() => setValue(value)}
|
|
152
|
+
className={cn(
|
|
153
|
+
"inline-flex h-10 items-center justify-center rounded-full px-4 text-body-sm font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50",
|
|
154
|
+
selected
|
|
155
|
+
? "bg-background text-foreground shadow-sm"
|
|
156
|
+
: "text-muted-foreground hover:text-foreground",
|
|
157
|
+
astroClass,
|
|
158
|
+
className,
|
|
159
|
+
)}
|
|
160
|
+
>
|
|
161
|
+
{children}
|
|
162
|
+
</button>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export interface TabsContentProps {
|
|
167
|
+
value: string;
|
|
168
|
+
class?: string;
|
|
169
|
+
className?: string;
|
|
170
|
+
children?: React.ReactNode;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function TabsContent({
|
|
174
|
+
value,
|
|
175
|
+
class: astroClass,
|
|
176
|
+
className,
|
|
177
|
+
children,
|
|
178
|
+
}: TabsContentProps) {
|
|
179
|
+
const { value: current, baseId } = useTabs("TabsContent");
|
|
180
|
+
const selected = current === value;
|
|
181
|
+
return (
|
|
182
|
+
<div
|
|
183
|
+
role="tabpanel"
|
|
184
|
+
id={`${baseId}-content-${value}`}
|
|
185
|
+
aria-labelledby={`${baseId}-trigger-${value}`}
|
|
186
|
+
hidden={!selected}
|
|
187
|
+
tabIndex={0}
|
|
188
|
+
data-slot="tabs-content"
|
|
189
|
+
data-state={selected ? "active" : "inactive"}
|
|
190
|
+
className={cn(
|
|
191
|
+
"mt-4 outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
|
192
|
+
astroClass,
|
|
193
|
+
className,
|
|
194
|
+
)}
|
|
195
|
+
>
|
|
196
|
+
{selected ? children : null}
|
|
197
|
+
</div>
|
|
198
|
+
);
|
|
199
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
|
|
3
|
+
import { cn } from "../lib/utils";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Textarea — native <textarea> styled to the Nyuchi Design System
|
|
7
|
+
* tokens (shadcn pattern over the Five-African-Minerals tokens).
|
|
8
|
+
* Dependency-free and SSR-friendly. Forwards every standard textarea
|
|
9
|
+
* prop (name, id, placeholder, required, rows, value, defaultValue, …)
|
|
10
|
+
* via `...props`.
|
|
11
|
+
*/
|
|
12
|
+
export const textareaClasses =
|
|
13
|
+
"flex min-h-24 w-full rounded-lg border border-border bg-background px-4 py-3 text-body text-foreground transition-colors placeholder:text-muted-foreground 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";
|
|
14
|
+
|
|
15
|
+
export interface TextareaProps
|
|
16
|
+
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
|
|
17
|
+
/** Astro-style class attribute (merged with `className`). */
|
|
18
|
+
class?: string;
|
|
19
|
+
className?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function Textarea({
|
|
23
|
+
class: astroClass,
|
|
24
|
+
className,
|
|
25
|
+
...props
|
|
26
|
+
}: TextareaProps) {
|
|
27
|
+
return (
|
|
28
|
+
<textarea
|
|
29
|
+
data-slot="textarea"
|
|
30
|
+
className={cn(textareaClasses, astroClass, className)}
|
|
31
|
+
{...props}
|
|
32
|
+
/>
|
|
33
|
+
);
|
|
34
|
+
}
|