@kahitsan/ksui 0.3.0 → 0.4.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.
Files changed (37) hide show
  1. package/README.md +109 -26
  2. package/host-ui.d.ts +4 -4
  3. package/package.json +7 -3
  4. package/src/components/{AccountAvatar.tsx → base/AccountAvatar.tsx} +10 -10
  5. package/src/components/base/ChartLegend.tsx +34 -0
  6. package/src/components/base/CopyButton.tsx +53 -0
  7. package/src/components/base/DetailRow.tsx +17 -0
  8. package/src/components/{ExistingAttachmentTile.tsx → base/ExistingAttachmentTile.tsx} +2 -2
  9. package/src/components/base/FormErrorBanner.tsx +27 -0
  10. package/src/components/base/FormField.tsx +19 -0
  11. package/src/components/base/ImageCropper.tsx +275 -0
  12. package/src/components/base/KpiCard.tsx +125 -0
  13. package/src/components/base/ProgressBar.tsx +328 -0
  14. package/src/components/base/RadioCardGroup.tsx +146 -0
  15. package/src/components/base/SegmentedFilter.tsx +50 -0
  16. package/src/components/base/StatusPill.tsx +90 -0
  17. package/src/components/base/TagPill.tsx +24 -0
  18. package/src/components/base/Tooltip.tsx +64 -0
  19. package/src/components/{ClientPicker.tsx → composite/ClientPicker.tsx} +1 -1
  20. package/src/components/composite/FormActions.tsx +79 -0
  21. package/src/components/composite/LiveTimer.tsx +434 -0
  22. package/src/components/{MarkdownNotes.tsx → composite/MarkdownNotes.tsx} +1 -1
  23. package/src/components/{PaymentAccountPicker.tsx → composite/PaymentAccountPicker.tsx} +2 -2
  24. package/src/components/composite/SecretReveal.tsx +63 -0
  25. package/src/components/{VoucherPicker.tsx → composite/VoucherPicker.tsx} +1 -1
  26. package/src/index.ts +84 -27
  27. package/src/utils/INPUT_CLASS.ts +7 -0
  28. package/src/{lib → utils}/account-logo-url.ts +1 -1
  29. package/src/{lib → utils}/accounts-index.tsx +2 -2
  30. package/src/{lib → utils}/attachments.ts +3 -3
  31. package/src/utils/formatFullDate.ts +14 -0
  32. package/src/utils/formatPHP.ts +13 -0
  33. package/src/utils/formatShortDate.ts +17 -0
  34. /package/src/components/{AddAttachmentTile.tsx → base/AddAttachmentTile.tsx} +0 -0
  35. /package/src/components/{CameraCapture.tsx → base/CameraCapture.tsx} +0 -0
  36. /package/src/components/{MentionTextarea.tsx → composite/MentionTextarea.tsx} +0 -0
  37. /package/src/{lib → utils}/account-icons.ts +0 -0
@@ -0,0 +1,328 @@
1
+ // Source: archive/pillar app/pillar-ui/base/ProgressBar/ProgressBar.tsx
2
+ // Ported into kserp so the counter cards can reuse the same realtime
3
+ // progress visualization the session manager uses on the home page.
4
+ // Adapted to kserp's TS + lucide-solid conventions.
5
+
6
+ import type { Component, JSX } from "solid-js";
7
+ import { createMemo, splitProps, Show } from "solid-js";
8
+ import { Dynamic } from "solid-js/web";
9
+
10
+ // The monolith ships these as a CSS module (ProgressBar.module.css). The
11
+ // plugin remote builds to a single IIFE that the host serves as one script —
12
+ // a sidecar .css emitted by the lib build would never get injected, so the
13
+ // progress fill/indicator/shimmer treatment would silently disappear. Inline
14
+ // the keyframes + helper classes once per page via a <style> tag and reference
15
+ // them with plain (unscoped) class names so the bundle stays self-contained.
16
+ const PROGRESS_STYLE_ID = "ks-progress-bar-inline-style";
17
+ const PROGRESS_CSS = `
18
+ .ks-progress-fill { position: relative; border-radius: 0; transition: width 1s cubic-bezier(0.4, 0, 0.2, 1); overflow: hidden; }
19
+ .ks-progress-indicator { border-radius: 0; box-shadow: 0 0 2px currentColor; }
20
+ .ks-progress-overflow { position: relative; }
21
+ @keyframes ksLiveTimerShimmer { 0% { transform: translateX(-100%) skewX(-25deg); } 100% { transform: translateX(100%) skewX(-25deg); } }
22
+ .ks-progress-shimmer { animation: ksLiveTimerShimmer 2s infinite; background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent); background-size: 200% 100%; height: 100%; width: 100%; position: absolute; top: 0; left: 0; opacity: 0.7; }
23
+ @media (prefers-reduced-motion: reduce) { .ks-progress-fill { transition: width 0.3s ease; } }
24
+ `;
25
+ function ensureProgressStyle() {
26
+ if (typeof document === "undefined") return;
27
+ if (document.getElementById(PROGRESS_STYLE_ID)) return;
28
+ const el = document.createElement("style");
29
+ el.id = PROGRESS_STYLE_ID;
30
+ el.textContent = PROGRESS_CSS;
31
+ document.head.appendChild(el);
32
+ }
33
+ const styles: Record<string, string> = {
34
+ "ks-progress-fill": "ks-progress-fill",
35
+ "ks-progress-indicator": "ks-progress-indicator",
36
+ "ks-progress-overflow": "ks-progress-overflow",
37
+ "animate-shimmer": "ks-progress-shimmer",
38
+ };
39
+
40
+ export interface ProgressBarProps extends JSX.HTMLAttributes<HTMLDivElement> {
41
+ progress: number;
42
+ icon?: Component<{ size: number; class?: string }>;
43
+ label?: string;
44
+ statusLabel?: string;
45
+ shimmer?: boolean;
46
+ position?: "left" | "right";
47
+ hidePercentage?: boolean;
48
+ // When set, this string replaces the percentage on the right side
49
+ // (whether or not `hidePercentage` is true). Lets callers like
50
+ // LiveTimer push the live countdown into the right slot while the
51
+ // total label sits on the left.
52
+ rightLabel?: string;
53
+ class?: string;
54
+ }
55
+
56
+ function cn(...classes: Array<string | undefined | null | false>): string {
57
+ return classes.filter(Boolean).join(" ");
58
+ }
59
+
60
+ interface ColorInfo {
61
+ fill: string;
62
+ indicator: string;
63
+ stripe: string;
64
+ overflow: string;
65
+ shimmer: string;
66
+ }
67
+
68
+ const COLOR_MAP: Record<string, ColorInfo> = {
69
+ red: {
70
+ fill: "rgba(255, 68, 68, 0.2)",
71
+ indicator: "#FF4444",
72
+ stripe: "rgba(255, 68, 68, 0.4)",
73
+ overflow: "rgba(255, 68, 68, 0.4)",
74
+ shimmer: "rgba(255, 68, 68, 0.3)",
75
+ },
76
+ green: {
77
+ fill: "rgba(0, 204, 136, 0.2)",
78
+ indicator: "#00CC88",
79
+ stripe: "rgba(0, 204, 136, 0.4)",
80
+ overflow: "rgba(0, 204, 136, 0.4)",
81
+ shimmer: "rgba(0, 204, 136, 0.3)",
82
+ },
83
+ blue: {
84
+ fill: "rgba(74, 158, 255, 0.2)",
85
+ indicator: "#4A9EFF",
86
+ stripe: "rgba(74, 158, 255, 0.4)",
87
+ overflow: "rgba(74, 158, 255, 0.4)",
88
+ shimmer: "rgba(74, 158, 255, 0.3)",
89
+ },
90
+ amber: {
91
+ fill: "rgba(245, 158, 11, 0.2)",
92
+ indicator: "#F59E0B",
93
+ stripe: "rgba(245, 158, 11, 0.4)",
94
+ overflow: "rgba(245, 158, 11, 0.4)",
95
+ shimmer: "rgba(245, 158, 11, 0.3)",
96
+ },
97
+ orange: {
98
+ fill: "rgba(255, 136, 51, 0.2)",
99
+ indicator: "#FF8833",
100
+ stripe: "rgba(255, 136, 51, 0.4)",
101
+ overflow: "rgba(255, 136, 51, 0.4)",
102
+ shimmer: "rgba(255, 136, 51, 0.3)",
103
+ },
104
+ purple: {
105
+ fill: "rgba(168, 85, 247, 0.2)",
106
+ indicator: "#A855F7",
107
+ stripe: "rgba(168, 85, 247, 0.4)",
108
+ overflow: "rgba(168, 85, 247, 0.4)",
109
+ shimmer: "rgba(168, 85, 247, 0.3)",
110
+ },
111
+ slate: {
112
+ fill: "rgba(148, 163, 184, 0.2)",
113
+ indicator: "#94A3B8",
114
+ stripe: "rgba(148, 163, 184, 0.4)",
115
+ overflow: "rgba(148, 163, 184, 0.4)",
116
+ shimmer: "rgba(148, 163, 184, 0.3)",
117
+ },
118
+ gray: {
119
+ fill: "rgba(156, 163, 175, 0.2)",
120
+ indicator: "#9CA3AF",
121
+ stripe: "rgba(156, 163, 175, 0.4)",
122
+ overflow: "rgba(156, 163, 175, 0.4)",
123
+ shimmer: "rgba(156, 163, 175, 0.3)",
124
+ },
125
+ zinc: {
126
+ fill: "rgba(161, 161, 170, 0.2)",
127
+ indicator: "#A1A1AA",
128
+ stripe: "rgba(161, 161, 170, 0.4)",
129
+ overflow: "rgba(161, 161, 170, 0.4)",
130
+ shimmer: "rgba(161, 161, 170, 0.3)",
131
+ },
132
+ };
133
+
134
+ function extractColorInfo(className: string): ColorInfo {
135
+ for (const [name, value] of Object.entries(COLOR_MAP)) {
136
+ if (className.includes(name)) return value;
137
+ }
138
+ return COLOR_MAP.green;
139
+ }
140
+
141
+ function extractTextSize(className: string): number {
142
+ if (className.includes("text-xs")) return 12;
143
+ if (className.includes("text-sm")) return 14;
144
+ if (className.includes("text-base")) return 16;
145
+ if (className.includes("text-lg")) return 18;
146
+ if (className.includes("text-xl")) return 20;
147
+ if (className.includes("text-2xl")) return 24;
148
+ return 14;
149
+ }
150
+
151
+ const ProgressBar: Component<ProgressBarProps> = (props) => {
152
+ ensureProgressStyle();
153
+ const [local, others] = splitProps(props, [
154
+ "progress",
155
+ "icon",
156
+ "label",
157
+ "statusLabel",
158
+ "shimmer",
159
+ "position",
160
+ "hidePercentage",
161
+ "rightLabel",
162
+ "class",
163
+ ]);
164
+
165
+ const progressVal = createMemo(() => {
166
+ const value = local.progress;
167
+ if (typeof value !== "number" || Number.isNaN(value)) return 0;
168
+ return Math.max(0, value);
169
+ });
170
+
171
+ const position = (): "left" | "right" => local.position ?? "left";
172
+ const shimmer = () => local.shimmer ?? false;
173
+ const statusLabel = () => local.statusLabel;
174
+ const label = () => local.label;
175
+ const hidePercentage = () => local.hidePercentage ?? false;
176
+ const classProp = () => local.class;
177
+
178
+ const progressInfo = createMemo(() => {
179
+ const raw = progressVal();
180
+ return { progress: Math.max(0, Math.min(100, raw)), overflow: Math.max(0, raw - 100) };
181
+ });
182
+
183
+ const colorInfo = createMemo(() => extractColorInfo(classProp() ?? ""));
184
+ const iconSize = createMemo(() => extractTextSize(classProp() ?? ""));
185
+
186
+ const containerClasses = createMemo(() =>
187
+ cn(
188
+ "select-none relative overflow-hidden rounded bg-black/20 border border-zinc-700/40",
189
+ "h-8",
190
+ !classProp()?.includes("w-") ? "w-full" : "",
191
+ classProp(),
192
+ ),
193
+ );
194
+
195
+ const progressStyle = createMemo(() => {
196
+ const colors = colorInfo();
197
+ const { progress } = progressInfo();
198
+ const isRight = position() === "right";
199
+ const style: Record<string, string | number> = {
200
+ width: `${progress}%`,
201
+ "background-color": colors.fill,
202
+ };
203
+ if (isRight) {
204
+ style.position = "absolute";
205
+ style.right = "0px";
206
+ style["border-radius"] = "0 4px 4px 0";
207
+ }
208
+ return style;
209
+ });
210
+
211
+ const overflowStyle = createMemo(() => {
212
+ const colors = colorInfo();
213
+ const { overflow } = progressInfo();
214
+ const isRight = position() === "right";
215
+ const visible = Math.min(90, overflow);
216
+ if (isRight) {
217
+ return {
218
+ width: `${visible}%`,
219
+ "background-color": colors.overflow,
220
+ position: "absolute" as const,
221
+ left: "0px",
222
+ top: "0px",
223
+ height: "100%",
224
+ "border-radius": "4px 0 0 4px",
225
+ };
226
+ }
227
+ const startPosition = Math.max(10, 100 - visible);
228
+ return {
229
+ width: `${visible}%`,
230
+ "background-color": colors.overflow,
231
+ position: "absolute" as const,
232
+ left: `${startPosition}%`,
233
+ top: "0px",
234
+ height: "100%",
235
+ "border-radius": "0 4px 4px 0",
236
+ };
237
+ });
238
+
239
+ const indicatorStyle = createMemo(() => {
240
+ const colors = colorInfo();
241
+ const isRight = position() === "right";
242
+ return {
243
+ "background-color": colors.indicator,
244
+ ...(isRight ? { left: "0px" } : { right: "0px" }),
245
+ };
246
+ });
247
+
248
+ const overflowIndicatorStyle = createMemo(() => {
249
+ const colors = colorInfo();
250
+ const isRight = position() === "right";
251
+ return {
252
+ "background-color": colors.indicator,
253
+ ...(isRight ? { right: "0px" } : { left: "0px" }),
254
+ };
255
+ });
256
+
257
+ const shimmerStyle = createMemo(() => ({
258
+ background: `linear-gradient(90deg, transparent, ${colorInfo().shimmer}, transparent)`,
259
+ }));
260
+
261
+ const Icon = () => local.icon;
262
+
263
+ return (
264
+ <div class={containerClasses()} {...others}>
265
+ <div
266
+ class={cn("h-full transition-all duration-1000 relative", styles["ks-progress-fill"])}
267
+ style={progressStyle()}
268
+ >
269
+ {shimmer() && progressInfo().overflow <= 0 && (
270
+ <div class={cn("absolute inset-0", styles["animate-shimmer"])} style={shimmerStyle()} />
271
+ )}
272
+ {progressInfo().overflow <= 0 && (
273
+ <div
274
+ class={cn("absolute top-0 w-1 h-full animate-pulse", styles["ks-progress-indicator"])}
275
+ style={indicatorStyle()}
276
+ />
277
+ )}
278
+ </div>
279
+ {progressInfo().overflow > 0 && (
280
+ <div
281
+ class={cn("transition-all duration-1000 animate-pulse", styles["ks-progress-overflow"])}
282
+ style={overflowStyle()}
283
+ >
284
+ <div
285
+ class={cn("absolute top-0 w-1 h-full animate-pulse", styles["ks-progress-indicator"])}
286
+ style={overflowIndicatorStyle()}
287
+ />
288
+ </div>
289
+ )}
290
+ <div class="absolute inset-0 flex items-center justify-between px-3">
291
+ <div class="flex items-center gap-2 min-w-0 flex-1">
292
+ {(() => {
293
+ const I = Icon();
294
+ return (
295
+ <Show when={I}>
296
+ <Dynamic component={I!} size={iconSize()} />
297
+ </Show>
298
+ );
299
+ })()}
300
+ {statusLabel() && (
301
+ <span class="font-mono font-semibold tabular-nums">{statusLabel()}</span>
302
+ )}
303
+ {label() && (
304
+ <>
305
+ {(Icon() || statusLabel()) && <span class="text-zinc-500 mx-1">·</span>}
306
+ <span class="flex-1 min-w-0">
307
+ <span class="text-zinc-400 block overflow-hidden text-ellipsis whitespace-nowrap">
308
+ {label()}
309
+ </span>
310
+ </span>
311
+ </>
312
+ )}
313
+ </div>
314
+ {local.rightLabel !== undefined ? (
315
+ <div class="flex-shrink-0 ml-2 text-right">
316
+ <span class="font-mono font-semibold tabular-nums">{local.rightLabel}</span>
317
+ </div>
318
+ ) : !hidePercentage() ? (
319
+ <div class="flex-shrink-0 ml-2 text-right">
320
+ <span class="font-mono font-medium text-zinc-400">{Math.round(progressVal())}%</span>
321
+ </div>
322
+ ) : null}
323
+ </div>
324
+ </div>
325
+ );
326
+ };
327
+
328
+ export default ProgressBar;
@@ -0,0 +1,146 @@
1
+ import { For, type JSX } from "solid-js";
2
+
3
+ interface RadioCardGroupProps<T> {
4
+ /** The selectable options. */
5
+ options: T[];
6
+ /** The currently selected key, or null when nothing is selected. */
7
+ value: string | null;
8
+ /** Fired with the chosen option's key when selection changes. */
9
+ onChange: (value: string) => void;
10
+ /** Derives the stable string key for an option (used for value matching). */
11
+ keyOf: (option: T) => string;
12
+ /**
13
+ * Renders the inner content of one card. Receives the option and whether it
14
+ * is selected. Defaults to a dot + label layout (see `labelOf`). Callers that
15
+ * need a richer layout (price, avatar, secondary line) pass their own.
16
+ */
17
+ renderOption?: (option: T, selected: boolean) => JSX.Element;
18
+ /**
19
+ * Derives the text label for the default `renderOption`. Falls back to
20
+ * String(keyOf(option)) when omitted. Ignored when `renderOption` is supplied.
21
+ */
22
+ labelOf?: (option: T) => string;
23
+ /** Accessible label for the radiogroup wrapper. */
24
+ ariaLabel: string;
25
+ /**
26
+ * Grid column behavior. A number sets a fixed column count; "auto" (default)
27
+ * uses the responsive 2-up / 3-up grid that the source pickers used.
28
+ */
29
+ columns?: number | "auto";
30
+ /** Extra classes on the wrapper grid. */
31
+ class?: string;
32
+ /**
33
+ * Extra classes applied to every item button. Use this to override the
34
+ * default amber-active styling per call site.
35
+ */
36
+ itemClass?: string;
37
+ }
38
+
39
+ // A controlled, keyboard-navigable group of radio cards. The reusable part is
40
+ // the roving-tabindex keyboard model that the transactions account picker and
41
+ // the subscriptions variant picker carried verbatim: arrow keys move and select
42
+ // with modulo wrap, Home/End jump to the ends, and exactly one item is a tab
43
+ // stop (the selected one, or the first item when nothing is selected).
44
+ //
45
+ // Domain content stays out of this component: the caller supplies `keyOf` to
46
+ // match the controlled `value` and an optional `renderOption` for the card body.
47
+ // The default body is a dot + label, which covers the simplest case; richer
48
+ // layouts (price lines, avatars) come in through `renderOption`.
49
+ export default function RadioCardGroup<T>(props: RadioCardGroupProps<T>): JSX.Element {
50
+ const buttonRefs: (HTMLButtonElement | undefined)[] = [];
51
+
52
+ const keyAt = (option: T) => props.keyOf(option);
53
+
54
+ const currentIndex = () => {
55
+ const i = props.options.findIndex((o) => keyAt(o) === props.value);
56
+ return i >= 0 ? i : 0;
57
+ };
58
+
59
+ const selectByIndex = (idx: number) => {
60
+ const list = props.options;
61
+ if (list.length === 0) return;
62
+ const wrapped = ((idx % list.length) + list.length) % list.length;
63
+ props.onChange(keyAt(list[wrapped]));
64
+ buttonRefs[wrapped]?.focus();
65
+ };
66
+
67
+ const onKeyDown = (e: KeyboardEvent) => {
68
+ switch (e.key) {
69
+ case "ArrowRight":
70
+ case "ArrowDown":
71
+ e.preventDefault();
72
+ selectByIndex(currentIndex() + 1);
73
+ break;
74
+ case "ArrowLeft":
75
+ case "ArrowUp":
76
+ e.preventDefault();
77
+ selectByIndex(currentIndex() - 1);
78
+ break;
79
+ case "Home":
80
+ e.preventDefault();
81
+ selectByIndex(0);
82
+ break;
83
+ case "End":
84
+ e.preventDefault();
85
+ selectByIndex(props.options.length - 1);
86
+ break;
87
+ }
88
+ };
89
+
90
+ const gridClass = () => {
91
+ const cols = props.columns ?? "auto";
92
+ if (cols === "auto") return "grid max-sm:grid-cols-2 sm:grid-cols-3 gap-2";
93
+ return `grid gap-2 grid-cols-${cols}`;
94
+ };
95
+
96
+ const label = (option: T) => props.labelOf?.(option) ?? String(props.keyOf(option));
97
+
98
+ const defaultRender = (option: T, selected: boolean) => (
99
+ <>
100
+ <span
101
+ class="h-2 w-2 rounded-full shrink-0"
102
+ classList={{
103
+ "bg-amber-400": selected,
104
+ "bg-zinc-600 group-hover:bg-zinc-500": !selected,
105
+ }}
106
+ />
107
+ <span class="truncate">{label(option)}</span>
108
+ </>
109
+ );
110
+
111
+ return (
112
+ <div
113
+ role="radiogroup"
114
+ aria-label={props.ariaLabel}
115
+ tabIndex={-1}
116
+ class={`${gridClass()} ${props.class ?? ""}`}
117
+ onKeyDown={onKeyDown}
118
+ >
119
+ <For each={props.options}>
120
+ {(option, i) => {
121
+ const k = keyAt(option);
122
+ const selected = () => props.value === k;
123
+ const isTabStop = () => selected() || (!props.value && i() === 0);
124
+ return (
125
+ <button
126
+ ref={(el) => (buttonRefs[i()] = el)}
127
+ type="button"
128
+ role="radio"
129
+ aria-checked={selected()}
130
+ tabIndex={isTabStop() ? 0 : -1}
131
+ onClick={() => props.onChange(k)}
132
+ class={`group flex items-center gap-2 rounded-lg border px-3 py-3 text-left text-sm transition-colors cursor-pointer ${props.itemClass ?? ""}`}
133
+ classList={{
134
+ "border-amber-500/50 bg-amber-600/10 text-amber-300": selected(),
135
+ "border-zinc-700 bg-zinc-800/50 text-zinc-300 hover:border-zinc-600 hover:bg-zinc-800":
136
+ !selected(),
137
+ }}
138
+ >
139
+ {(props.renderOption ?? defaultRender)(option, selected())}
140
+ </button>
141
+ );
142
+ }}
143
+ </For>
144
+ </div>
145
+ );
146
+ }
@@ -0,0 +1,50 @@
1
+ import { For, type JSX } from "solid-js";
2
+
3
+ /** One choice in the segmented row. A bare string uses the value as the label
4
+ * and is rendered capitalized; an object lets the caller supply an explicit
5
+ * label that is NOT capitalized (for buckets or other non-status toggles). */
6
+ export type SegmentedFilterOption = string | { value: string; label: string };
7
+
8
+ interface SegmentedFilterProps {
9
+ /** The available segments, left to right. */
10
+ options: SegmentedFilterOption[];
11
+ /** The currently active value. Matched against each option's value. */
12
+ value: string;
13
+ /** Called with the chosen value when a segment is clicked. */
14
+ onChange: (value: string) => void;
15
+ /** Prefix for each segment's data-testid (`${prefix}-${value}`). */
16
+ testIdPrefix?: string;
17
+ /** Extra classes on the outer bordered row. */
18
+ class?: string;
19
+ }
20
+
21
+ // A rounded bordered row of segment buttons with one active at a time.
22
+ // Presentational only and domain free: the caller passes the segment values
23
+ // and the active value, so there are no status literals baked in here.
24
+ export default function SegmentedFilter(props: SegmentedFilterProps): JSX.Element {
25
+ const optionOf = (o: SegmentedFilterOption) =>
26
+ typeof o === "string" ? { value: o, label: o, capitalize: true } : { ...o, capitalize: false };
27
+ return (
28
+ <div class={`flex rounded-lg border border-zinc-800/50 overflow-hidden ${props.class ?? ""}`}>
29
+ <For each={props.options}>
30
+ {(o) => {
31
+ const opt = optionOf(o);
32
+ return (
33
+ <button
34
+ data-testid={props.testIdPrefix ? `${props.testIdPrefix}-${opt.value}` : undefined}
35
+ onClick={() => props.onChange(opt.value)}
36
+ class="px-3 py-1.5 text-xs transition-colors cursor-pointer"
37
+ classList={{
38
+ capitalize: opt.capitalize,
39
+ "bg-amber-500/20 text-amber-400": props.value === opt.value,
40
+ "text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800/50": props.value !== opt.value,
41
+ }}
42
+ >
43
+ {opt.label}
44
+ </button>
45
+ );
46
+ }}
47
+ </For>
48
+ </div>
49
+ );
50
+ }
@@ -0,0 +1,90 @@
1
+ import { Show, type JSX } from "solid-js";
2
+
3
+ export type StatusTone = "success" | "neutral" | "warning" | "danger" | "info";
4
+
5
+ interface ToneClass {
6
+ text: string;
7
+ /** Lighter background (default, the packages-style /10). */
8
+ bg: string;
9
+ /** Heavier background (the clients/vouchers-style /20, used when solid). */
10
+ bgSolid: string;
11
+ border: string;
12
+ /** Fill color for the optional leading dot. */
13
+ dot: string;
14
+ }
15
+
16
+ // Module-private tone palette. The caller maps its own domain enum
17
+ // (status / is_active / voucher state) to one of these tones and passes a
18
+ // plain label; nothing domain-specific leaks into this atom.
19
+ const TONE_CLASS: Record<StatusTone, ToneClass> = {
20
+ success: {
21
+ text: "text-emerald-400",
22
+ bg: "bg-emerald-500/10",
23
+ bgSolid: "bg-emerald-500/20",
24
+ border: "border-emerald-400/40",
25
+ dot: "bg-emerald-400",
26
+ },
27
+ neutral: {
28
+ text: "text-zinc-400",
29
+ bg: "bg-zinc-800/60",
30
+ bgSolid: "bg-zinc-700/40",
31
+ border: "border-zinc-600",
32
+ dot: "bg-zinc-400",
33
+ },
34
+ warning: {
35
+ text: "text-amber-400",
36
+ bg: "bg-amber-500/10",
37
+ bgSolid: "bg-amber-500/20",
38
+ border: "border-amber-400/40",
39
+ dot: "bg-amber-400",
40
+ },
41
+ danger: {
42
+ text: "text-red-400",
43
+ bg: "bg-red-500/10",
44
+ bgSolid: "bg-red-500/20",
45
+ border: "border-red-400/40",
46
+ dot: "bg-red-400",
47
+ },
48
+ info: {
49
+ text: "text-blue-400",
50
+ bg: "bg-blue-500/10",
51
+ bgSolid: "bg-blue-500/20",
52
+ border: "border-blue-400/40",
53
+ dot: "bg-blue-400",
54
+ },
55
+ };
56
+
57
+ interface StatusPillProps {
58
+ /** Text shown inside the pill (the caller's own label). */
59
+ label: string;
60
+ /** Domain-free tone selector. The caller maps its enum to one of these. */
61
+ tone: StatusTone;
62
+ /** Render a leading filled dot in the tone's dot color. */
63
+ dot?: boolean;
64
+ /** Heavier /20 background (clients/vouchers style); default is the
65
+ * lighter /10 background (packages style). */
66
+ solid?: boolean;
67
+ /** Extra classes on the pill wrapper. */
68
+ class?: string;
69
+ testId?: string;
70
+ }
71
+
72
+ // Single status-pill atom. Baseline is the uppercase, wide-tracked,
73
+ // text-[10px] chip from the packages StatusPill. The optional dot covers the
74
+ // clients is_active pill, and solid covers the heavier clients/vouchers
75
+ // backgrounds. Domain enum -> tone + label mapping stays with the caller.
76
+ export default function StatusPill(props: StatusPillProps): JSX.Element {
77
+ const tc = () => TONE_CLASS[props.tone];
78
+ const bg = () => (props.solid ? tc().bgSolid : tc().bg);
79
+ return (
80
+ <span
81
+ data-testid={props.testId}
82
+ class={`inline-flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded border uppercase tracking-wider ${tc().text} ${tc().border} ${bg()} ${props.class ?? ""}`}
83
+ >
84
+ <Show when={props.dot}>
85
+ <span class={`h-1.5 w-1.5 rounded-full ${tc().dot}`} aria-hidden="true" />
86
+ </Show>
87
+ {props.label}
88
+ </span>
89
+ );
90
+ }
@@ -0,0 +1,24 @@
1
+ import { type JSX } from "solid-js";
2
+
3
+ interface TagPillProps {
4
+ /** The text to render inside the pill. */
5
+ label: string;
6
+ /** Extra classes on the pill span. */
7
+ class?: string;
8
+ }
9
+
10
+ // A tiny, intentionally neutral rounded-full chip for category/tag lists.
11
+ // It is strictly zinc with no tone, no dot, and no uppercase, which keeps it
12
+ // visually distinct from StatusPill. When a caller needs a colored chip they
13
+ // should reach for StatusPill instead. Domain-free and presentational only.
14
+ export default function TagPill(props: TagPillProps): JSX.Element {
15
+ return (
16
+ <span
17
+ class={`inline-block rounded-full border border-zinc-700 bg-zinc-800/50 px-2 py-0.5 text-[10px] text-zinc-400 ${
18
+ props.class ?? ""
19
+ }`}
20
+ >
21
+ {props.label}
22
+ </span>
23
+ );
24
+ }