@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.
@@ -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
+ }
@@ -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
+ }
@@ -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
+ }
@@ -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
+ }