rastack 0.0.26 → 0.0.27

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,291 @@
1
+ import React, { useState } from "react";
2
+ import {
3
+ tokens,
4
+ mergeStyles,
5
+ controlBaseStyle,
6
+ controlSizing,
7
+ focusRingStyle,
8
+ invalidControlStyle,
9
+ buttonVariantStyle,
10
+ type ButtonVariant,
11
+ type ControlSize,
12
+ } from "../style";
13
+ import { Spinner } from "./misc";
14
+
15
+ /** Track focus so a control can apply the shared token focus ring on the web. */
16
+ function useFocusRing<
17
+ E extends { onFocus?: unknown; onBlur?: unknown },
18
+ >(handlers: {
19
+ onFocus?: React.FocusEventHandler<never>;
20
+ onBlur?: React.FocusEventHandler<never>;
21
+ }): {
22
+ focused: boolean;
23
+ onFocus: React.FocusEventHandler<never>;
24
+ onBlur: React.FocusEventHandler<never>;
25
+ } {
26
+ const [focused, setFocused] = useState(false);
27
+ return {
28
+ focused,
29
+ onFocus: (e) => {
30
+ setFocused(true);
31
+ handlers.onFocus?.(e);
32
+ },
33
+ onBlur: (e) => {
34
+ setFocused(false);
35
+ handlers.onBlur?.(e);
36
+ },
37
+ };
38
+ }
39
+
40
+ // --- Button -------------------------------------------------------------------
41
+
42
+ export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
43
+ variant?: ButtonVariant;
44
+ size?: ControlSize;
45
+ /** Show a spinner and block interaction. */
46
+ loading?: boolean;
47
+ /** Stretch to the container width. */
48
+ block?: boolean;
49
+ }
50
+
51
+ /** A token-styled button with five variants and three sizes. */
52
+ export function Button({
53
+ variant = "primary",
54
+ size = "md",
55
+ loading = false,
56
+ block = false,
57
+ disabled = false,
58
+ style,
59
+ children,
60
+ ...rest
61
+ }: ButtonProps): React.JSX.Element {
62
+ const s = controlSizing[size];
63
+ const isDisabled = disabled || loading;
64
+ const base: React.CSSProperties = {
65
+ display: block ? "flex" : "inline-flex",
66
+ width: block ? "100%" : undefined,
67
+ alignItems: "center",
68
+ justifyContent: "center",
69
+ gap: tokens.space.sm,
70
+ padding: `${s.paddingY} ${s.paddingX}`,
71
+ minHeight: s.height,
72
+ fontFamily: tokens.font.sans,
73
+ fontSize: s.fontSize,
74
+ fontWeight: tokens.font.weightMedium as unknown as number,
75
+ lineHeight: 1,
76
+ borderRadius: tokens.radius.md,
77
+ cursor: isDisabled ? "not-allowed" : "pointer",
78
+ opacity: isDisabled ? 0.6 : 1,
79
+ transition: `background ${tokens.duration.fast} ease, opacity ${tokens.duration.fast} ease`,
80
+ ...buttonVariantStyle[variant],
81
+ };
82
+ return (
83
+ <button
84
+ type="button"
85
+ style={mergeStyles(base, style)}
86
+ disabled={isDisabled}
87
+ {...rest}
88
+ >
89
+ {loading ? (
90
+ <Spinner
91
+ size={Math.round(parseFloat(s.fontSize) * 14)}
92
+ color="currentColor"
93
+ />
94
+ ) : null}
95
+ {children}
96
+ </button>
97
+ );
98
+ }
99
+
100
+ // --- Input --------------------------------------------------------------------
101
+
102
+ export interface InputProps extends Omit<
103
+ React.InputHTMLAttributes<HTMLInputElement>,
104
+ "size"
105
+ > {
106
+ size?: ControlSize;
107
+ /** Paint the invalid (danger) border. */
108
+ invalid?: boolean;
109
+ }
110
+
111
+ /** A token-styled text input (also `type="number"`, `email`, `date`, …). */
112
+ export function Input({
113
+ size = "md",
114
+ invalid = false,
115
+ style,
116
+ onFocus,
117
+ onBlur,
118
+ ...rest
119
+ }: InputProps): React.JSX.Element {
120
+ const ring = useFocusRing({ onFocus, onBlur });
121
+ const base: React.CSSProperties = {
122
+ ...controlBaseStyle(size),
123
+ ...(invalid ? invalidControlStyle : null),
124
+ ...(ring.focused ? focusRingStyle : null),
125
+ };
126
+ return (
127
+ <input
128
+ style={mergeStyles(base, style)}
129
+ onFocus={ring.onFocus}
130
+ onBlur={ring.onBlur}
131
+ {...rest}
132
+ />
133
+ );
134
+ }
135
+
136
+ // --- Textarea -----------------------------------------------------------------
137
+
138
+ export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
139
+ size?: ControlSize;
140
+ invalid?: boolean;
141
+ }
142
+
143
+ /** A token-styled multi-line text input. */
144
+ export function Textarea({
145
+ size = "md",
146
+ invalid = false,
147
+ style,
148
+ onFocus,
149
+ onBlur,
150
+ rows = 3,
151
+ ...rest
152
+ }: TextareaProps): React.JSX.Element {
153
+ const ring = useFocusRing({ onFocus, onBlur });
154
+ const base: React.CSSProperties = {
155
+ ...controlBaseStyle(size),
156
+ ...(invalid ? invalidControlStyle : null),
157
+ ...(ring.focused ? focusRingStyle : null),
158
+ resize: "vertical",
159
+ minHeight: "auto",
160
+ height: "auto",
161
+ };
162
+ return (
163
+ <textarea
164
+ rows={rows}
165
+ style={mergeStyles(base, style)}
166
+ onFocus={ring.onFocus}
167
+ onBlur={ring.onBlur}
168
+ {...rest}
169
+ />
170
+ );
171
+ }
172
+
173
+ // --- Select -------------------------------------------------------------------
174
+
175
+ export interface SelectOption {
176
+ label: string;
177
+ value: string | number;
178
+ }
179
+
180
+ export interface SelectProps extends Omit<
181
+ React.SelectHTMLAttributes<HTMLSelectElement>,
182
+ "size"
183
+ > {
184
+ size?: ControlSize;
185
+ invalid?: boolean;
186
+ /** Options to render. Omit and pass `<option>` children for full control. */
187
+ options?: SelectOption[];
188
+ /** A leading, empty, disabled option — the "choose…" affordance. */
189
+ placeholder?: string;
190
+ }
191
+
192
+ /** A token-styled native `<select>`. */
193
+ export function Select({
194
+ size = "md",
195
+ invalid = false,
196
+ options,
197
+ placeholder,
198
+ style,
199
+ onFocus,
200
+ onBlur,
201
+ children,
202
+ ...rest
203
+ }: SelectProps): React.JSX.Element {
204
+ const ring = useFocusRing({ onFocus, onBlur });
205
+ const base: React.CSSProperties = {
206
+ ...controlBaseStyle(size),
207
+ ...(invalid ? invalidControlStyle : null),
208
+ ...(ring.focused ? focusRingStyle : null),
209
+ appearance: "none",
210
+ cursor: "pointer",
211
+ // room for the chevron
212
+ paddingRight: tokens.space.xl,
213
+ backgroundImage:
214
+ "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath d='M2 4l4 4 4-4' fill='none' stroke='%23888' stroke-width='1.5'/%3E%3C/svg%3E\")",
215
+ backgroundRepeat: "no-repeat",
216
+ backgroundPosition: `right ${tokens.space.sm} center`,
217
+ };
218
+ return (
219
+ <select
220
+ style={mergeStyles(base, style)}
221
+ onFocus={ring.onFocus}
222
+ onBlur={ring.onBlur}
223
+ {...rest}
224
+ >
225
+ {placeholder !== undefined ? (
226
+ <option value="" disabled>
227
+ {placeholder}
228
+ </option>
229
+ ) : null}
230
+ {options
231
+ ? options.map((o) => (
232
+ <option key={String(o.value)} value={o.value}>
233
+ {o.label}
234
+ </option>
235
+ ))
236
+ : children}
237
+ </select>
238
+ );
239
+ }
240
+
241
+ // --- Checkbox -----------------------------------------------------------------
242
+
243
+ export interface CheckboxProps extends Omit<
244
+ React.InputHTMLAttributes<HTMLInputElement>,
245
+ "type"
246
+ > {
247
+ /** An inline label rendered to the right of the box. */
248
+ label?: React.ReactNode;
249
+ }
250
+
251
+ /** A token-styled checkbox (accent colour follows the theme). */
252
+ export function Checkbox({
253
+ label,
254
+ style,
255
+ disabled,
256
+ ...rest
257
+ }: CheckboxProps): React.JSX.Element {
258
+ const box = (
259
+ <input
260
+ type="checkbox"
261
+ disabled={disabled}
262
+ style={mergeStyles(
263
+ {
264
+ width: "1rem",
265
+ height: "1rem",
266
+ accentColor: tokens.color.accent,
267
+ cursor: disabled ? "not-allowed" : "pointer",
268
+ },
269
+ style,
270
+ )}
271
+ {...rest}
272
+ />
273
+ );
274
+ if (label === undefined) return box;
275
+ return (
276
+ <label
277
+ style={{
278
+ display: "inline-flex",
279
+ alignItems: "center",
280
+ gap: tokens.space.sm,
281
+ fontFamily: tokens.font.sans,
282
+ fontSize: "0.875rem",
283
+ color: tokens.color.text,
284
+ cursor: disabled ? "not-allowed" : "pointer",
285
+ }}
286
+ >
287
+ {box}
288
+ {label}
289
+ </label>
290
+ );
291
+ }
@@ -0,0 +1,117 @@
1
+ import React from "react";
2
+ import { tokens, mergeStyles } from "../style";
3
+
4
+ export interface LabelProps extends React.LabelHTMLAttributes<HTMLLabelElement> {
5
+ /** Append a danger-coloured `*`. */
6
+ required?: boolean;
7
+ }
8
+
9
+ /** A token-styled form label. */
10
+ export function Label({
11
+ required = false,
12
+ style,
13
+ children,
14
+ ...rest
15
+ }: LabelProps): React.JSX.Element {
16
+ const base: React.CSSProperties = {
17
+ display: "inline-block",
18
+ fontFamily: tokens.font.sans,
19
+ fontSize: "0.8125rem",
20
+ fontWeight: tokens.font.weightMedium as unknown as number,
21
+ color: tokens.color.text,
22
+ };
23
+ return (
24
+ <label style={mergeStyles(base, style)} {...rest}>
25
+ {children}
26
+ {required ? (
27
+ <span style={{ color: tokens.color.danger, marginLeft: 2 }}>*</span>
28
+ ) : null}
29
+ </label>
30
+ );
31
+ }
32
+
33
+ /** Muted helper text under a control. */
34
+ export function FieldHint({
35
+ style,
36
+ children,
37
+ ...rest
38
+ }: React.HTMLAttributes<HTMLDivElement>): React.JSX.Element {
39
+ const base: React.CSSProperties = {
40
+ fontFamily: tokens.font.sans,
41
+ fontSize: "0.75rem",
42
+ color: tokens.color.textMuted,
43
+ };
44
+ return (
45
+ <div style={mergeStyles(base, style)} {...rest}>
46
+ {children}
47
+ </div>
48
+ );
49
+ }
50
+
51
+ /** A danger-coloured validation message. */
52
+ export function FieldError({
53
+ style,
54
+ children,
55
+ ...rest
56
+ }: React.HTMLAttributes<HTMLDivElement>): React.JSX.Element {
57
+ const base: React.CSSProperties = {
58
+ fontFamily: tokens.font.sans,
59
+ fontSize: "0.75rem",
60
+ color: tokens.color.danger,
61
+ };
62
+ return (
63
+ <div role="alert" style={mergeStyles(base, style)} {...rest}>
64
+ {children}
65
+ </div>
66
+ );
67
+ }
68
+
69
+ export interface FieldProps {
70
+ /** The label text; omit for an unlabelled field. */
71
+ label?: React.ReactNode;
72
+ /** Associates the label with the control. */
73
+ htmlFor?: string;
74
+ required?: boolean;
75
+ /** When truthy, shown as a `<FieldError>` (and hides the hint). */
76
+ error?: string;
77
+ /** Helper text shown when there is no error. */
78
+ hint?: React.ReactNode;
79
+ /** The control itself. */
80
+ children: React.ReactNode;
81
+ style?: React.CSSProperties;
82
+ }
83
+
84
+ /**
85
+ * The label + control + message stack used by `<AutoForm>`, also usable on its
86
+ * own to lay out a hand-built control.
87
+ */
88
+ export function Field({
89
+ label,
90
+ htmlFor,
91
+ required,
92
+ error,
93
+ hint,
94
+ children,
95
+ style,
96
+ }: FieldProps): React.JSX.Element {
97
+ const base: React.CSSProperties = {
98
+ display: "flex",
99
+ flexDirection: "column",
100
+ gap: tokens.space.xs,
101
+ };
102
+ return (
103
+ <div style={mergeStyles(base, style)}>
104
+ {label != null ? (
105
+ <Label htmlFor={htmlFor} required={required}>
106
+ {label}
107
+ </Label>
108
+ ) : null}
109
+ {children}
110
+ {error ? (
111
+ <FieldError>{error}</FieldError>
112
+ ) : hint ? (
113
+ <FieldHint>{hint}</FieldHint>
114
+ ) : null}
115
+ </div>
116
+ );
117
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * The token-styled building blocks — the "shadcn layer". Every one paints
3
+ * exclusively from design tokens (see `../style.ts`) and forwards `style` /
4
+ * `className` / native props, so you can compose or restyle freely. The
5
+ * data-connected components (`<DataTable>`, `<AutoForm>`) are built on top of
6
+ * these.
7
+ */
8
+ export * from "./controls";
9
+ export * from "./field";
10
+ export * from "./table";
11
+ export * from "./misc";
@@ -0,0 +1,173 @@
1
+ import React, { useEffect } from "react";
2
+ import { tokens, mergeStyles } from "../style";
3
+
4
+ /**
5
+ * One-time injection of the keyframes the kit needs (only the spinner today).
6
+ * Guarded so it runs once per document and no-ops under SSR / React Native.
7
+ */
8
+ let keyframesInjected = false;
9
+ function useKitKeyframes(): void {
10
+ useEffect(() => {
11
+ if (keyframesInjected || typeof document === "undefined") return;
12
+ keyframesInjected = true;
13
+ const el = document.createElement("style");
14
+ el.setAttribute("data-rastack-ui", "");
15
+ el.textContent = "@keyframes rastack-spin{to{transform:rotate(360deg)}}";
16
+ document.head.appendChild(el);
17
+ }, []);
18
+ }
19
+
20
+ export interface SpinnerProps {
21
+ /** Diameter in px. Default 16. */
22
+ size?: number;
23
+ /** Ring colour. Defaults to the accent token. */
24
+ color?: string;
25
+ style?: React.CSSProperties;
26
+ "aria-label"?: string;
27
+ }
28
+
29
+ /** An indeterminate loading ring, painted from tokens. */
30
+ export function Spinner({
31
+ size = 16,
32
+ color,
33
+ style,
34
+ ...rest
35
+ }: SpinnerProps): React.JSX.Element {
36
+ useKitKeyframes();
37
+ const base: React.CSSProperties = {
38
+ display: "inline-block",
39
+ width: size,
40
+ height: size,
41
+ border: `2px solid ${tokens.color.border}`,
42
+ borderTopColor: color ?? tokens.color.accent,
43
+ borderRadius: "50%",
44
+ animation: "rastack-spin 0.6s linear infinite",
45
+ flex: "none",
46
+ };
47
+ return (
48
+ <span
49
+ role="status"
50
+ aria-label={rest["aria-label"] ?? "Loading"}
51
+ style={mergeStyles(base, style)}
52
+ />
53
+ );
54
+ }
55
+
56
+ export type BadgeTone = "neutral" | "accent" | "success" | "danger" | "warning";
57
+
58
+ const badgeTone: Record<BadgeTone, React.CSSProperties> = {
59
+ neutral: {
60
+ background: tokens.color.surfaceAlt,
61
+ color: tokens.color.textMuted,
62
+ },
63
+ accent: { background: tokens.color.accent, color: tokens.color.onAccent },
64
+ success: { background: tokens.color.success, color: "#ffffff" },
65
+ danger: { background: tokens.color.danger, color: "#ffffff" },
66
+ warning: { background: tokens.color.warning, color: "#1a1205" },
67
+ };
68
+
69
+ export interface BadgeProps extends React.HTMLAttributes<HTMLSpanElement> {
70
+ tone?: BadgeTone;
71
+ }
72
+
73
+ /** A small status pill. */
74
+ export function Badge({
75
+ tone = "neutral",
76
+ style,
77
+ children,
78
+ ...rest
79
+ }: BadgeProps): React.JSX.Element {
80
+ const base: React.CSSProperties = {
81
+ display: "inline-flex",
82
+ alignItems: "center",
83
+ gap: tokens.space.xs,
84
+ padding: `2px ${tokens.space.sm}`,
85
+ fontFamily: tokens.font.sans,
86
+ fontSize: "0.75rem",
87
+ fontWeight: tokens.font.weightMedium as unknown as number,
88
+ lineHeight: 1.4,
89
+ borderRadius: tokens.radius.pill,
90
+ whiteSpace: "nowrap",
91
+ ...badgeTone[tone],
92
+ };
93
+ return (
94
+ <span style={mergeStyles(base, style)} {...rest}>
95
+ {children}
96
+ </span>
97
+ );
98
+ }
99
+
100
+ export interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
101
+ /** Drop the default padding (e.g. to hold a flush `<DataTable>`). */
102
+ flush?: boolean;
103
+ }
104
+
105
+ /** A surface container: token background, border, radius and shadow. */
106
+ export function Card({
107
+ flush,
108
+ style,
109
+ children,
110
+ ...rest
111
+ }: CardProps): React.JSX.Element {
112
+ const base: React.CSSProperties = {
113
+ background: tokens.color.surface,
114
+ border: `1px solid ${tokens.color.border}`,
115
+ borderRadius: tokens.radius.lg,
116
+ boxShadow: tokens.shadow.sm,
117
+ padding: flush ? 0 : tokens.space.lg,
118
+ color: tokens.color.text,
119
+ overflow: "hidden",
120
+ };
121
+ return (
122
+ <div style={mergeStyles(base, style)} {...rest}>
123
+ {children}
124
+ </div>
125
+ );
126
+ }
127
+
128
+ type SpaceKey = keyof typeof tokens.space;
129
+
130
+ function resolveGap(
131
+ gap: SpaceKey | number | string | undefined,
132
+ ): string | number | undefined {
133
+ if (gap == null) return undefined;
134
+ if (typeof gap === "number") return gap;
135
+ return (tokens.space as Record<string, string>)[gap] ?? gap;
136
+ }
137
+
138
+ export interface StackProps extends React.HTMLAttributes<HTMLDivElement> {
139
+ direction?: "row" | "column";
140
+ /** A `space` token key (`"md"`), a number (px) or any CSS length. */
141
+ gap?: SpaceKey | number | string;
142
+ align?: React.CSSProperties["alignItems"];
143
+ justify?: React.CSSProperties["justifyContent"];
144
+ wrap?: boolean;
145
+ inline?: boolean;
146
+ }
147
+
148
+ /** A flexbox layout primitive with token-aware `gap`. */
149
+ export function Stack({
150
+ direction = "column",
151
+ gap = "md",
152
+ align,
153
+ justify,
154
+ wrap,
155
+ inline,
156
+ style,
157
+ children,
158
+ ...rest
159
+ }: StackProps): React.JSX.Element {
160
+ const base: React.CSSProperties = {
161
+ display: inline ? "inline-flex" : "flex",
162
+ flexDirection: direction,
163
+ gap: resolveGap(gap),
164
+ alignItems: align,
165
+ justifyContent: justify,
166
+ flexWrap: wrap ? "wrap" : undefined,
167
+ };
168
+ return (
169
+ <div style={mergeStyles(base, style)} {...rest}>
170
+ {children}
171
+ </div>
172
+ );
173
+ }