@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.
- package/README.md +109 -26
- package/host-ui.d.ts +4 -4
- package/package.json +7 -3
- package/src/components/{AccountAvatar.tsx → base/AccountAvatar.tsx} +10 -10
- package/src/components/base/ChartLegend.tsx +34 -0
- package/src/components/base/CopyButton.tsx +53 -0
- package/src/components/base/DetailRow.tsx +17 -0
- package/src/components/{ExistingAttachmentTile.tsx → base/ExistingAttachmentTile.tsx} +2 -2
- package/src/components/base/FormErrorBanner.tsx +27 -0
- package/src/components/base/FormField.tsx +19 -0
- package/src/components/base/ImageCropper.tsx +275 -0
- package/src/components/base/KpiCard.tsx +125 -0
- package/src/components/base/ProgressBar.tsx +328 -0
- package/src/components/base/RadioCardGroup.tsx +146 -0
- package/src/components/base/SegmentedFilter.tsx +50 -0
- package/src/components/base/StatusPill.tsx +90 -0
- package/src/components/base/TagPill.tsx +24 -0
- package/src/components/base/Tooltip.tsx +64 -0
- package/src/components/{ClientPicker.tsx → composite/ClientPicker.tsx} +1 -1
- package/src/components/composite/FormActions.tsx +79 -0
- package/src/components/composite/LiveTimer.tsx +434 -0
- package/src/components/{MarkdownNotes.tsx → composite/MarkdownNotes.tsx} +1 -1
- package/src/components/{PaymentAccountPicker.tsx → composite/PaymentAccountPicker.tsx} +2 -2
- package/src/components/composite/SecretReveal.tsx +63 -0
- package/src/components/{VoucherPicker.tsx → composite/VoucherPicker.tsx} +1 -1
- package/src/index.ts +84 -27
- package/src/utils/INPUT_CLASS.ts +7 -0
- package/src/{lib → utils}/account-logo-url.ts +1 -1
- package/src/{lib → utils}/accounts-index.tsx +2 -2
- package/src/{lib → utils}/attachments.ts +3 -3
- package/src/utils/formatFullDate.ts +14 -0
- package/src/utils/formatPHP.ts +13 -0
- package/src/utils/formatShortDate.ts +17 -0
- /package/src/components/{AddAttachmentTile.tsx → base/AddAttachmentTile.tsx} +0 -0
- /package/src/components/{CameraCapture.tsx → base/CameraCapture.tsx} +0 -0
- /package/src/components/{MentionTextarea.tsx → composite/MentionTextarea.tsx} +0 -0
- /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
|
+
}
|