@kahitsan/ksui 0.5.0 → 0.7.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kahitsan/ksui",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "ksui is a set of shared SolidJS UI components plus the @kserp/host-ui type contract for KahitSan/Hilinga plugins. Published to the public npm registry and consumed as a normal dependency. Ships source under a `solid` export condition so the consumer's vite-plugin-solid compiles it with solid-js + @kserp/host-ui externalized to the host runtime.",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -0,0 +1,400 @@
1
+ // Source: kahitsan-web src/components/ui/Button/Button.tsx (+ assets/css/button.css).
2
+ // Extracted into ksui as a DOMAIN-FREE base primitive: an intent/variant button
3
+ // with HUD scanline, clip-corner, ripple, glow, and pulse effects. It depends
4
+ // only on solid-js (Dynamic) — no host kit, no kahitsan imports, no marketing
5
+ // copy. The intent palette (amber/red/slate rgba maps) ships as the generic
6
+ // default tone set; callers pass intent + variant and their own children.
7
+ //
8
+ // The monolith shipped these effects as global CSS (button.css). ksui publishes
9
+ // no sidecar .css (the package exports only ./src under the `solid` condition),
10
+ // so — like ProgressBar / LiveTimer — the keyframes + helper classes are
11
+ // injected once per page via a runtime <style> tag and referenced with plain,
12
+ // unscoped class names so the bundle stays self-contained.
13
+
14
+ import {
15
+ splitProps,
16
+ createSignal,
17
+ onCleanup,
18
+ createMemo,
19
+ mergeProps,
20
+ Show,
21
+ For,
22
+ type JSX,
23
+ type Component,
24
+ } from "solid-js";
25
+ import { Dynamic } from "solid-js/web";
26
+
27
+ const BUTTON_STYLE_ID = "ks-button-inline-style";
28
+ const BUTTON_CSS = `
29
+ .ks-hud-scan-line { position: relative; overflow: hidden; }
30
+ .ks-hud-scan-line::before { content: ''; position: absolute; top: 0; left: -100%; width: 100%; height: 100%; opacity: 0; pointer-events: none; z-index: 1; transition: opacity 0.2s ease; background: linear-gradient(90deg, transparent, var(--ks-effect-color, rgba(201, 169, 97, 0.4)), transparent); }
31
+ .ks-hud-scan-line:hover::before { left: 100%; opacity: 1; transition: all 0.8s ease; }
32
+ .ks-hud-clip-top-left-bottom-right { clip-path: polygon(6px 0, 100% 0, 100% calc(100% - 6px), calc(100% - 6px) 100%, 0 100%, 0 6px); border: none !important; border-radius: 0 !important; }
33
+ .ks-hud-clip-top-right-bottom-left { clip-path: polygon(0 0, calc(100% - 6px) 0, 100% 6px, 100% 100%, 6px 100%, 0 calc(100% - 6px)); border: none !important; border-radius: 0 !important; }
34
+ .ks-hud-clip-minimal-top-left-bottom-right { clip-path: polygon(4px 0, 100% 0, 100% calc(100% - 4px), calc(100% - 4px) 100%, 0 100%, 0 4px); border: none !important; border-radius: 0 !important; }
35
+ .ks-hud-clip-minimal-top-right-bottom-left { clip-path: polygon(0 0, calc(100% - 4px) 0, 100% 4px, 100% 100%, 4px 100%, 0 calc(100% - 4px)); border: none !important; border-radius: 0 !important; }
36
+ .ks-hud-glow { transition: box-shadow 0.2s ease; box-shadow: 0 0 8px var(--ks-effect-glow, rgba(201, 169, 97, 0.3)); }
37
+ .ks-hud-glow:hover:not(:disabled) { box-shadow: 0 0 15px var(--ks-effect-glow-hover, rgba(201, 169, 97, 0.5)); }
38
+ .ks-hud-pulse { animation: ksHudPulse 2s infinite ease-in-out; }
39
+ @keyframes ksHudPulse { 0%, 100% { background-color: var(--ks-effect-pulse-bg, rgba(201, 169, 97, 0.1)); box-shadow: 0 0 5px var(--ks-effect-pulse-glow, rgba(201, 169, 97, 0.2)); } 50% { background-color: var(--ks-effect-pulse-bg-mid, rgba(201, 169, 97, 0.2)); box-shadow: 0 0 10px var(--ks-effect-pulse-glow-mid, rgba(201, 169, 97, 0.4)); } }
40
+ .ks-btn-ripple { position: relative; overflow: hidden; }
41
+ .ks-btn-ripple-effect { position: absolute; border-radius: 50%; transform: scale(0.1); opacity: 0.8; animation: ksButtonRippleExpand 0.4s ease-out forwards; pointer-events: none; z-index: 0; background-color: var(--ks-ripple-color, rgba(255, 255, 255, 0.3)); }
42
+ .ks-btn-ripple-fade { animation: ksButtonRippleFadeOnly 0.3s ease-out forwards; }
43
+ @keyframes ksButtonRippleExpand { 0% { transform: scale(0.1); opacity: 0.5; } 100% { transform: scale(1); opacity: 0.25; } }
44
+ @keyframes ksButtonRippleFadeOnly { 0% { opacity: 0.3; transform: scale(1); } 100% { opacity: 0; transform: scale(1.1); } }
45
+ .ks-btn-ripple > *:not(.ks-btn-ripple-effect) { position: relative; z-index: 2; }
46
+ .ks-interactive { transition: all 0.15s ease; }
47
+ .ks-interactive:active:not(:disabled) { transform: scale(0.97); opacity: 0.8; }
48
+ .ks-interactive:hover:not(:disabled) { transform: scale(1.02); }
49
+ `;
50
+
51
+ function ensureButtonStyle() {
52
+ if (typeof document === "undefined") return;
53
+ if (document.getElementById(BUTTON_STYLE_ID)) return;
54
+ const el = document.createElement("style");
55
+ el.id = BUTTON_STYLE_ID;
56
+ el.textContent = BUTTON_CSS;
57
+ document.head.appendChild(el);
58
+ }
59
+
60
+ const styles: Record<string, string> = {
61
+ "ks-hud-scan-line": "ks-hud-scan-line",
62
+ "ks-hud-clip-top-left-bottom-right": "ks-hud-clip-top-left-bottom-right",
63
+ "ks-hud-clip-top-right-bottom-left": "ks-hud-clip-top-right-bottom-left",
64
+ "ks-hud-clip-minimal-top-left-bottom-right": "ks-hud-clip-minimal-top-left-bottom-right",
65
+ "ks-hud-clip-minimal-top-right-bottom-left": "ks-hud-clip-minimal-top-right-bottom-left",
66
+ "ks-hud-glow": "ks-hud-glow",
67
+ "ks-hud-pulse": "ks-hud-pulse",
68
+ "ks-btn-ripple": "ks-btn-ripple",
69
+ "ks-btn-ripple-effect": "ks-btn-ripple-effect",
70
+ "ks-btn-ripple-fade": "ks-btn-ripple-fade",
71
+ "ks-interactive": "ks-interactive",
72
+ };
73
+
74
+ export type ButtonIntent = "primary" | "danger" | "secondary";
75
+ export type ButtonVariant = "clip1" | "clip2" | "ghost" | "link";
76
+
77
+ export interface ButtonProps {
78
+ /** Element / component to render as (defaults to "button"). */
79
+ as?: string | Component<Record<string, unknown>>;
80
+ intent?: ButtonIntent;
81
+ variant?: ButtonVariant;
82
+ noRipple?: boolean;
83
+ noScanline?: boolean;
84
+ noGlow?: boolean;
85
+ noPulse?: boolean;
86
+ icon?: (props: { size?: number; class?: string }) => JSX.Element;
87
+ iconPosition?: "left" | "right";
88
+ class?: string;
89
+ disabled?: boolean;
90
+ children?: JSX.Element;
91
+ type?: string;
92
+ onClick?: (event: MouseEvent) => void;
93
+ [key: string]: unknown;
94
+ }
95
+
96
+ function cn(...classes: Array<string | undefined | null | false>): string {
97
+ return classes.filter(Boolean).join(" ");
98
+ }
99
+
100
+ interface IntentColors {
101
+ ripple: string;
102
+ effect: string;
103
+ effectBg: string;
104
+ effectBorder: string;
105
+ effectGlow: string;
106
+ }
107
+
108
+ interface IntentConfig {
109
+ textColor: string;
110
+ background: string;
111
+ hover: string;
112
+ colors: IntentColors;
113
+ }
114
+
115
+ const buttonIntentConfig: Record<ButtonIntent, IntentConfig> = {
116
+ primary: {
117
+ textColor: "text-amber-400",
118
+ background: "bg-amber-600/20 border-amber-600/60",
119
+ hover: "hover:bg-amber-600/30 hover:border-amber-500",
120
+ colors: {
121
+ ripple: "rgba(255, 255, 255, 0.3)",
122
+ effect: "rgba(201, 169, 97, 0.4)",
123
+ effectBg: "rgba(201, 169, 97, 0.1)",
124
+ effectBorder: "rgba(201, 169, 97, 0.4)",
125
+ effectGlow: "rgba(201, 169, 97, 0.3)",
126
+ },
127
+ },
128
+ danger: {
129
+ textColor: "text-red-400",
130
+ background: "bg-red-600/20 border-red-600/60",
131
+ hover: "hover:bg-red-600/30 hover:border-red-500",
132
+ colors: {
133
+ ripple: "rgba(255, 255, 255, 0.3)",
134
+ effect: "rgba(255, 68, 68, 0.4)",
135
+ effectBg: "rgba(255, 68, 68, 0.1)",
136
+ effectBorder: "rgba(255, 68, 68, 0.4)",
137
+ effectGlow: "rgba(255, 68, 68, 0.3)",
138
+ },
139
+ },
140
+ secondary: {
141
+ textColor: "text-slate-400",
142
+ background: "bg-slate-600/20 border-slate-600/60",
143
+ hover: "hover:bg-slate-600/30 hover:border-slate-500",
144
+ colors: {
145
+ ripple: "rgba(255, 255, 255, 0.3)",
146
+ effect: "rgba(148, 163, 184, 0.4)",
147
+ effectBg: "rgba(148, 163, 184, 0.1)",
148
+ effectBorder: "rgba(148, 163, 184, 0.4)",
149
+ effectGlow: "rgba(148, 163, 184, 0.3)",
150
+ },
151
+ },
152
+ };
153
+
154
+ interface VariantConfig {
155
+ effects: string[];
156
+ baseClasses: string;
157
+ overrideType: boolean;
158
+ }
159
+
160
+ const buttonVariantConfig: Record<ButtonVariant, VariantConfig> = {
161
+ clip1: {
162
+ effects: ["clip-top-left-bottom-right"],
163
+ baseClasses: "px-4 py-2 border",
164
+ overrideType: false,
165
+ },
166
+ clip2: {
167
+ effects: ["clip-top-right-bottom-left"],
168
+ baseClasses: "px-4 py-2 border",
169
+ overrideType: false,
170
+ },
171
+ ghost: {
172
+ effects: [],
173
+ baseClasses: "px-4 py-2 bg-transparent border-transparent hover:bg-current/10 hover:border-transparent",
174
+ overrideType: true,
175
+ },
176
+ link: {
177
+ effects: [],
178
+ baseClasses:
179
+ "px-0 py-0 bg-transparent border-transparent underline-offset-4 hover:underline hover:bg-transparent hover:border-transparent",
180
+ overrideType: true,
181
+ },
182
+ };
183
+
184
+ interface Ripple {
185
+ id: number;
186
+ x: number;
187
+ y: number;
188
+ size: number;
189
+ isFading: boolean;
190
+ }
191
+
192
+ const Button = (props: ButtonProps): JSX.Element => {
193
+ ensureButtonStyle();
194
+
195
+ const merged = mergeProps(
196
+ {
197
+ as: "button" as ButtonProps["as"],
198
+ intent: "primary" as ButtonIntent,
199
+ variant: "clip1" as ButtonVariant,
200
+ noRipple: false,
201
+ noScanline: false,
202
+ noGlow: false,
203
+ noPulse: false,
204
+ iconPosition: "left" as const,
205
+ },
206
+ props,
207
+ );
208
+
209
+ const [local, others] = splitProps(merged, [
210
+ "as",
211
+ "intent",
212
+ "variant",
213
+ "noRipple",
214
+ "noScanline",
215
+ "noGlow",
216
+ "noPulse",
217
+ "icon",
218
+ "iconPosition",
219
+ "class",
220
+ "children",
221
+ "onClick",
222
+ "disabled",
223
+ ]);
224
+
225
+ const [ripples, setRipples] = createSignal<Ripple[]>([]);
226
+
227
+ let elementRef: HTMLElement | undefined;
228
+ let mouseDownTime = 0;
229
+
230
+ const isIconOnly = createMemo(() => !local.children && local.icon);
231
+ const intentConfig = createMemo(
232
+ () => buttonIntentConfig[local.intent as ButtonIntent] || buttonIntentConfig.primary,
233
+ );
234
+ const variantConfig = createMemo(
235
+ () => buttonVariantConfig[local.variant as ButtonVariant] || buttonVariantConfig.clip1,
236
+ );
237
+
238
+ const hasRipple = createMemo(() => {
239
+ if (local.noRipple) return false;
240
+ if (local.variant === "link") return false;
241
+ return true;
242
+ });
243
+
244
+ const activeEffects = createMemo(() => {
245
+ const effects: string[] = [...variantConfig().effects];
246
+ if (!local.noScanline) effects.push("scanline");
247
+ return effects;
248
+ });
249
+
250
+ const effectClasses = createMemo(() =>
251
+ activeEffects()
252
+ .map((effect) => {
253
+ const effectMap: Record<string, string> = {
254
+ scanline: styles["ks-hud-scan-line"],
255
+ "clip-top-left-bottom-right": styles["ks-hud-clip-top-left-bottom-right"],
256
+ "clip-top-right-bottom-left": styles["ks-hud-clip-top-right-bottom-left"],
257
+ "clip-minimal-top-left-bottom-right": styles["ks-hud-clip-minimal-top-left-bottom-right"],
258
+ "clip-minimal-top-right-bottom-left": styles["ks-hud-clip-minimal-top-right-bottom-left"],
259
+ glow: styles["ks-hud-glow"],
260
+ pulse: styles["ks-hud-pulse"],
261
+ };
262
+ return effectMap[effect];
263
+ })
264
+ .filter(Boolean),
265
+ );
266
+
267
+ const customProperties = createMemo(() => {
268
+ const colors = intentConfig().colors;
269
+ return {
270
+ "--ks-effect-color": colors.effect,
271
+ "--ks-effect-bg": colors.effectBg,
272
+ "--ks-effect-border": colors.effectBorder,
273
+ "--ks-effect-bg-hover": colors.effect.replace("0.1", "0.2"),
274
+ "--ks-effect-border-hover": colors.effect.replace("0.4", "0.6"),
275
+ "--ks-effect-glow": colors.effectGlow,
276
+ "--ks-effect-glow-hover": colors.effectGlow.replace("0.3", "0.5"),
277
+ "--ks-effect-pulse-bg": colors.effectBg,
278
+ "--ks-effect-pulse-bg-mid": colors.effect.replace("0.4", "0.2"),
279
+ "--ks-effect-pulse-glow": colors.effectGlow.replace("0.3", "0.2"),
280
+ "--ks-effect-pulse-glow-mid": colors.effectGlow,
281
+ "--ks-ripple-color": colors.ripple,
282
+ } as JSX.CSSProperties;
283
+ });
284
+
285
+ const classes = createMemo(() => {
286
+ const coreClasses =
287
+ "select-none inline-flex items-center justify-center gap-2 font-medium transition-all duration-200 focus:outline-none text-sm rounded";
288
+ const textColor = intentConfig().textColor;
289
+ const backgroundClasses = variantConfig().overrideType
290
+ ? ""
291
+ : `${intentConfig().background} ${intentConfig().hover}`;
292
+ const variantClasses = variantConfig().baseClasses;
293
+
294
+ return cn(
295
+ coreClasses,
296
+ textColor,
297
+ backgroundClasses,
298
+ variantClasses,
299
+ local.class,
300
+ hasRipple() && styles["ks-btn-ripple"],
301
+ !hasRipple() && styles["ks-interactive"],
302
+ isIconOnly() && "aspect-square !p-2",
303
+ ...effectClasses(),
304
+ local.disabled && "opacity-50 cursor-not-allowed",
305
+ );
306
+ });
307
+
308
+ const handleMouseDown = (event: MouseEvent) => {
309
+ if (local.disabled) return;
310
+ if (hasRipple() && elementRef) {
311
+ mouseDownTime = Date.now();
312
+ const rect = elementRef.getBoundingClientRect();
313
+ const x = event.clientX - rect.left;
314
+ const y = event.clientY - rect.top;
315
+ const size = Math.max(rect.width, rect.height) * 2;
316
+ const rippleId = Date.now() + Math.random();
317
+ setRipples((prev) => [...prev, { id: rippleId, x, y, size, isFading: false }]);
318
+ }
319
+ };
320
+
321
+ const handleMouseUp = () => {
322
+ if (!hasRipple() || local.disabled) return;
323
+ const timeSinceMouseDown = Date.now() - mouseDownTime;
324
+ const delayBeforeFade = Math.max(0, 400 - timeSinceMouseDown);
325
+ setTimeout(() => {
326
+ setRipples((prev) => prev.map((r) => ({ ...r, isFading: true })));
327
+ setTimeout(() => setRipples([]), 400);
328
+ }, delayBeforeFade);
329
+ };
330
+
331
+ const handleMouseLeave = () => {
332
+ if (!local.disabled) handleMouseUp();
333
+ };
334
+
335
+ const handleClick = (event: MouseEvent) => {
336
+ if (local.onClick && !local.disabled) local.onClick(event);
337
+ };
338
+
339
+ const renderContent = createMemo(() => {
340
+ const IconComponent = local.icon;
341
+ if (isIconOnly() && IconComponent) return <IconComponent />;
342
+ if (IconComponent && local.children) {
343
+ const icon = <IconComponent />;
344
+ return local.iconPosition === "left" ? (
345
+ <>
346
+ {icon}
347
+ {local.children}
348
+ </>
349
+ ) : (
350
+ <>
351
+ {local.children}
352
+ {icon}
353
+ </>
354
+ );
355
+ }
356
+ if (IconComponent) return <IconComponent />;
357
+ return local.children;
358
+ });
359
+
360
+ onCleanup(() => setRipples([]));
361
+
362
+ return (
363
+ <Dynamic
364
+ component={local.as}
365
+ ref={(el: HTMLElement) => {
366
+ elementRef = el;
367
+ const maybeRef = (others as { ref?: unknown }).ref;
368
+ if (typeof maybeRef === "function") (maybeRef as (e: HTMLElement) => void)(el);
369
+ }}
370
+ class={classes()}
371
+ style={customProperties()}
372
+ onMouseDown={handleMouseDown}
373
+ onMouseUp={handleMouseUp}
374
+ onMouseLeave={handleMouseLeave}
375
+ onClick={handleClick}
376
+ disabled={local.disabled}
377
+ {...others}
378
+ >
379
+ {renderContent()}
380
+ <Show when={hasRipple()}>
381
+ <For each={ripples()}>
382
+ {(ripple) => (
383
+ <span
384
+ class={cn(styles["ks-btn-ripple-effect"], ripple.isFading && styles["ks-btn-ripple-fade"])}
385
+ style={{
386
+ left: `${ripple.x - ripple.size / 2}px`,
387
+ top: `${ripple.y - ripple.size / 2}px`,
388
+ width: `${ripple.size}px`,
389
+ height: `${ripple.size}px`,
390
+ "background-color": "var(--ks-ripple-color, rgba(255, 255, 255, 0.3))",
391
+ }}
392
+ />
393
+ )}
394
+ </For>
395
+ </Show>
396
+ </Dynamic>
397
+ );
398
+ };
399
+
400
+ export default Button;
@@ -0,0 +1,164 @@
1
+ import { createSignal, For, onCleanup, onMount, Show, type Component, type JSX } from "solid-js";
2
+ import { Dynamic } from "solid-js/web";
3
+ import ChevronDown from "lucide-solid/icons/chevron-down";
4
+
5
+ /** A lucide-solid icon component (or any component taking size/class). */
6
+ type IconComponent = Component<{ size?: number; class?: string }>;
7
+
8
+ /** The status of a dropdown item, used to render a small leading colored dot
9
+ * when no explicit icon is supplied. Domain-free: the caller maps its own
10
+ * enum onto one of these three presentational states. */
11
+ export type DropdownItemStatus = "open" | "closed" | "neutral";
12
+
13
+ export interface DropdownItem {
14
+ /** Stable identity, matched against the selected `value`. */
15
+ id: string;
16
+ /** Primary text shown for the item and on the trigger when selected. */
17
+ label: string;
18
+ /** Optional secondary line under the label. */
19
+ description?: string;
20
+ /** Arbitrary payload handed back to `onSelect` unchanged. */
21
+ value?: unknown;
22
+ /** Optional leading icon. Takes precedence over the status dot. */
23
+ icon?: IconComponent;
24
+ /** Optional pill rendered beside the label. */
25
+ badge?: string;
26
+ /** When true the item is dimmed and not selectable. */
27
+ disabled?: boolean;
28
+ /** When no `icon` is set, renders a small colored status dot. */
29
+ status?: DropdownItemStatus;
30
+ }
31
+
32
+ export interface DropdownProps {
33
+ /** The selectable options, top to bottom. */
34
+ items: DropdownItem[];
35
+ /** The currently selected item's `id`. Uncontrolled fallback selects the first item. */
36
+ value?: string;
37
+ /** Trigger text when nothing is selected. */
38
+ placeholder?: string;
39
+ /** Optional leading icon on the trigger button. */
40
+ icon?: IconComponent;
41
+ /** Minimum width of the trigger/menu (any CSS length). Defaults to 280px. */
42
+ minWidth?: string;
43
+ /** Disables the whole control. */
44
+ disabled?: boolean;
45
+ /** Called with the chosen item when an option is selected. */
46
+ onSelect?: (item: DropdownItem) => void;
47
+ /** Extra classes on the outer container. */
48
+ class?: string;
49
+ }
50
+
51
+ // Maps a presentational status to a dot color. Domain-free: only the three
52
+ // status states are recognized; everything else falls back to neutral gray.
53
+ function statusColor(status?: DropdownItemStatus): string {
54
+ switch (status) {
55
+ case "open":
56
+ return "#10B981";
57
+ case "closed":
58
+ return "#6B7280";
59
+ default:
60
+ return "#6B7280";
61
+ }
62
+ }
63
+
64
+ // A generic single-select dropdown: a labeled trigger that opens a panel of
65
+ // items, each optionally carrying a description, icon (or status dot), and
66
+ // badge. Click-outside closes the panel; the chevron rotates while open.
67
+ // Presentational only and domain-free — no baked-in copy or business literals.
68
+ export default function Dropdown(props: DropdownProps): JSX.Element {
69
+ const [isOpen, setIsOpen] = createSignal(false);
70
+ const [selectedItem, setSelectedItem] = createSignal<DropdownItem | undefined>(
71
+ props.items.find((item) => item.id === props.value),
72
+ );
73
+
74
+ const currentSelection = () => selectedItem() ?? props.items.find((i) => i.id === props.value) ?? props.items[0];
75
+
76
+ onMount(() => {
77
+ const handleClickOutside = (e: MouseEvent) => {
78
+ const target = e.target as Element | null;
79
+ if (!target?.closest("[data-dropdown-container]")) {
80
+ setIsOpen(false);
81
+ }
82
+ };
83
+ document.addEventListener("click", handleClickOutside);
84
+ onCleanup(() => document.removeEventListener("click", handleClickOutside));
85
+ });
86
+
87
+ const handleSelect = (item: DropdownItem) => {
88
+ if (item.disabled) return;
89
+ setSelectedItem(item);
90
+ setIsOpen(false);
91
+ props.onSelect?.(item);
92
+ };
93
+
94
+ return (
95
+ <div class={`relative inline-block ${props.class ?? ""}`} data-dropdown-container>
96
+ <button
97
+ onClick={() => !props.disabled && setIsOpen(!isOpen())}
98
+ disabled={props.disabled}
99
+ class={`flex items-center gap-3 px-4 py-3 bg-zinc-900/50 border border-zinc-800/50 rounded-lg hover:bg-zinc-900/70 transition-all backdrop-blur-sm select-none ${
100
+ props.disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer"
101
+ }`}
102
+ style={{ "min-width": props.minWidth ?? "280px" }}
103
+ >
104
+ <Show when={props.icon}>
105
+ {(Icon) => <Dynamic component={Icon()} size={18} class="text-amber-500" />}
106
+ </Show>
107
+ <div class="flex-1 text-left">
108
+ <div class="font-semibold text-white">
109
+ {currentSelection()?.label ?? props.placeholder ?? "Select an option"}
110
+ </div>
111
+ <Show when={currentSelection()?.description}>
112
+ <div class="text-xs text-zinc-400">{currentSelection()?.description}</div>
113
+ </Show>
114
+ </div>
115
+ <ChevronDown size={18} class={`text-zinc-400 transition-transform ${isOpen() ? "rotate-180" : ""}`} />
116
+ </button>
117
+
118
+ <Show when={isOpen()}>
119
+ <div class="absolute top-full left-0 mt-2 w-full bg-zinc-900/95 border border-zinc-800/50 rounded-lg backdrop-blur-xl z-50 shadow-xl select-none">
120
+ <For each={props.items}>
121
+ {(item) => (
122
+ <button
123
+ onClick={() => handleSelect(item)}
124
+ disabled={item.disabled}
125
+ class={`w-full px-4 py-3 flex items-center gap-3 hover:bg-zinc-800/50 transition-all first:rounded-t-lg last:rounded-b-lg select-none ${
126
+ currentSelection()?.id === item.id ? "bg-zinc-800/30" : ""
127
+ } ${item.disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer"}`}
128
+ >
129
+ <div class="flex items-center gap-2 flex-shrink-0">
130
+ <Show
131
+ when={item.icon}
132
+ fallback={
133
+ <Show when={item.status}>
134
+ <div
135
+ class="w-2 h-2 rounded-full flex-shrink-0"
136
+ style={{ background: statusColor(item.status) }}
137
+ />
138
+ </Show>
139
+ }
140
+ >
141
+ {(Icon) => <Dynamic component={Icon()} size={16} class="text-amber-500" />}
142
+ </Show>
143
+ </div>
144
+ <div class="flex-1 text-left min-w-0">
145
+ <div class="font-medium text-white flex items-center gap-2">
146
+ {item.label}
147
+ <Show when={item.badge}>
148
+ <span class="px-2 py-0.5 text-xs font-medium bg-zinc-700/50 text-zinc-400 rounded flex-shrink-0">
149
+ {item.badge}
150
+ </span>
151
+ </Show>
152
+ </div>
153
+ <Show when={item.description}>
154
+ <div class="text-xs text-zinc-400 truncate">{item.description}</div>
155
+ </Show>
156
+ </div>
157
+ </button>
158
+ )}
159
+ </For>
160
+ </div>
161
+ </Show>
162
+ </div>
163
+ );
164
+ }
@@ -0,0 +1,108 @@
1
+ import { Show, type JSX } from "solid-js";
2
+
3
+ export type EyebrowTone = "amber" | "blue" | "emerald" | "red" | "zinc";
4
+
5
+ interface ToneClass {
6
+ /** Text color for the kicker label. */
7
+ text: string;
8
+ /** Tinted background, used by the bordered pill variant. */
9
+ bg: string;
10
+ /** Accent border color, used by the bordered pill variant. */
11
+ border: string;
12
+ }
13
+
14
+ // Module-private tone palette. The caller picks a tone; nothing
15
+ // domain-specific leaks into this atom. Each tone is a flat color family so
16
+ // the badge reads as a quiet accent rather than a loud chip.
17
+ const TONE_CLASS: Record<EyebrowTone, ToneClass> = {
18
+ amber: {
19
+ text: "text-amber-400",
20
+ bg: "bg-amber-500/10",
21
+ border: "border-amber-500",
22
+ },
23
+ blue: {
24
+ text: "text-blue-400",
25
+ bg: "bg-blue-500/10",
26
+ border: "border-blue-500",
27
+ },
28
+ emerald: {
29
+ text: "text-emerald-400",
30
+ bg: "bg-emerald-500/10",
31
+ border: "border-emerald-500",
32
+ },
33
+ red: {
34
+ text: "text-red-400",
35
+ bg: "bg-red-500/10",
36
+ border: "border-red-500",
37
+ },
38
+ zinc: {
39
+ text: "text-zinc-500",
40
+ bg: "bg-zinc-800/50",
41
+ border: "border-zinc-600",
42
+ },
43
+ };
44
+
45
+ // Tracking presets for the wide letter-spacing. "normal" is Tailwind's
46
+ // tracking-widest; the wider presets cover the heading kickers that use
47
+ // arbitrary tracking values.
48
+ const TRACKING_CLASS = {
49
+ normal: "tracking-widest",
50
+ wide: "tracking-[0.2em]",
51
+ wider: "tracking-[0.3em]",
52
+ } as const;
53
+
54
+ export type EyebrowTracking = keyof typeof TRACKING_CLASS;
55
+
56
+ export interface EyebrowBadgeProps {
57
+ /** The kicker text. Rendered uppercase via CSS, so pass natural casing. */
58
+ label: string;
59
+ /** Color family for the label (and the accent border when bordered). */
60
+ tone?: EyebrowTone;
61
+ /**
62
+ * Render the bordered pill variant: a tinted background with a left accent
63
+ * border (the hero-style kicker). When false (default) it is a plain
64
+ * tracked-text kicker with no box.
65
+ */
66
+ bordered?: boolean;
67
+ /** Letter-spacing preset. */
68
+ tracking?: EyebrowTracking;
69
+ /** Render as an inline-block <div> instead of an inline <span>. */
70
+ block?: boolean;
71
+ /** Extra classes on the wrapper. */
72
+ class?: string;
73
+ testId?: string;
74
+ }
75
+
76
+ // A tiny, domain-free eyebrow / kicker atom: an uppercase, wide-tracked,
77
+ // bold micro-label. The plain variant is just tracked text; the bordered
78
+ // variant wraps it in a tinted pill with a left accent border. Presentational
79
+ // only — the caller supplies the label and picks a tone.
80
+ export default function EyebrowBadge(props: EyebrowBadgeProps): JSX.Element {
81
+ const tc = () => TONE_CLASS[props.tone ?? "amber"];
82
+ const tracking = () => TRACKING_CLASS[props.tracking ?? "normal"];
83
+
84
+ const base = () =>
85
+ `text-xs font-bold uppercase ${tracking()} ${tc().text}`;
86
+ const borderedExtra = () =>
87
+ props.bordered
88
+ ? `inline-block px-4 py-1 border-l-2 ${tc().bg} ${tc().border}`
89
+ : "";
90
+
91
+ const className = () =>
92
+ `${base()} ${borderedExtra()} ${props.class ?? ""}`.trim();
93
+
94
+ return (
95
+ <Show
96
+ when={props.block || props.bordered}
97
+ fallback={
98
+ <span data-testid={props.testId} class={className()}>
99
+ {props.label}
100
+ </span>
101
+ }
102
+ >
103
+ <div data-testid={props.testId} class={`inline-block ${className()}`}>
104
+ {props.label}
105
+ </div>
106
+ </Show>
107
+ );
108
+ }