@kahitsan/ksui 0.6.0 → 0.7.1
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 +7 -1
- package/src/components/base/Button.tsx +404 -0
- package/src/components/base/Dropdown.tsx +164 -0
- package/src/components/base/EyebrowBadge.tsx +108 -0
- package/src/components/base/SectionHeading.tsx +100 -0
- package/src/components/base/SocialLinksBar.tsx +92 -0
- package/src/components/base/StatusIndicator.tsx +110 -0
- package/src/components/base/ThemeToggle.tsx +112 -0
- package/src/components/composite/NotFound.tsx +130 -0
- package/src/index.ts +9 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kahitsan/ksui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.1",
|
|
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",
|
|
@@ -14,6 +14,12 @@
|
|
|
14
14
|
},
|
|
15
15
|
"./host-ui": {
|
|
16
16
|
"types": "./host-ui.d.ts"
|
|
17
|
+
},
|
|
18
|
+
"./components/*": {
|
|
19
|
+
"solid": "./src/components/*.tsx",
|
|
20
|
+
"types": "./src/components/*.tsx",
|
|
21
|
+
"development": "./src/components/*.tsx",
|
|
22
|
+
"import": "./src/components/*.tsx"
|
|
17
23
|
}
|
|
18
24
|
},
|
|
19
25
|
"files": [
|
|
@@ -0,0 +1,404 @@
|
|
|
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"). Intentionally
|
|
79
|
+
* permissive: a polymorphic `as` must accept anything from a tag string to a
|
|
80
|
+
* router link component with its own required props (e.g. SolidJS Router's
|
|
81
|
+
* `A`, which requires `href`), so it is typed `any` rather than a narrow
|
|
82
|
+
* `Component<Record<string, unknown>>` that would reject those callers. */
|
|
83
|
+
as?: any;
|
|
84
|
+
intent?: ButtonIntent;
|
|
85
|
+
variant?: ButtonVariant;
|
|
86
|
+
noRipple?: boolean;
|
|
87
|
+
noScanline?: boolean;
|
|
88
|
+
noGlow?: boolean;
|
|
89
|
+
noPulse?: boolean;
|
|
90
|
+
icon?: (props: { size?: number; class?: string }) => JSX.Element;
|
|
91
|
+
iconPosition?: "left" | "right";
|
|
92
|
+
class?: string;
|
|
93
|
+
disabled?: boolean;
|
|
94
|
+
children?: JSX.Element;
|
|
95
|
+
type?: string;
|
|
96
|
+
onClick?: (event: MouseEvent) => void;
|
|
97
|
+
[key: string]: unknown;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function cn(...classes: Array<string | undefined | null | false>): string {
|
|
101
|
+
return classes.filter(Boolean).join(" ");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
interface IntentColors {
|
|
105
|
+
ripple: string;
|
|
106
|
+
effect: string;
|
|
107
|
+
effectBg: string;
|
|
108
|
+
effectBorder: string;
|
|
109
|
+
effectGlow: string;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
interface IntentConfig {
|
|
113
|
+
textColor: string;
|
|
114
|
+
background: string;
|
|
115
|
+
hover: string;
|
|
116
|
+
colors: IntentColors;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const buttonIntentConfig: Record<ButtonIntent, IntentConfig> = {
|
|
120
|
+
primary: {
|
|
121
|
+
textColor: "text-amber-400",
|
|
122
|
+
background: "bg-amber-600/20 border-amber-600/60",
|
|
123
|
+
hover: "hover:bg-amber-600/30 hover:border-amber-500",
|
|
124
|
+
colors: {
|
|
125
|
+
ripple: "rgba(255, 255, 255, 0.3)",
|
|
126
|
+
effect: "rgba(201, 169, 97, 0.4)",
|
|
127
|
+
effectBg: "rgba(201, 169, 97, 0.1)",
|
|
128
|
+
effectBorder: "rgba(201, 169, 97, 0.4)",
|
|
129
|
+
effectGlow: "rgba(201, 169, 97, 0.3)",
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
danger: {
|
|
133
|
+
textColor: "text-red-400",
|
|
134
|
+
background: "bg-red-600/20 border-red-600/60",
|
|
135
|
+
hover: "hover:bg-red-600/30 hover:border-red-500",
|
|
136
|
+
colors: {
|
|
137
|
+
ripple: "rgba(255, 255, 255, 0.3)",
|
|
138
|
+
effect: "rgba(255, 68, 68, 0.4)",
|
|
139
|
+
effectBg: "rgba(255, 68, 68, 0.1)",
|
|
140
|
+
effectBorder: "rgba(255, 68, 68, 0.4)",
|
|
141
|
+
effectGlow: "rgba(255, 68, 68, 0.3)",
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
secondary: {
|
|
145
|
+
textColor: "text-slate-400",
|
|
146
|
+
background: "bg-slate-600/20 border-slate-600/60",
|
|
147
|
+
hover: "hover:bg-slate-600/30 hover:border-slate-500",
|
|
148
|
+
colors: {
|
|
149
|
+
ripple: "rgba(255, 255, 255, 0.3)",
|
|
150
|
+
effect: "rgba(148, 163, 184, 0.4)",
|
|
151
|
+
effectBg: "rgba(148, 163, 184, 0.1)",
|
|
152
|
+
effectBorder: "rgba(148, 163, 184, 0.4)",
|
|
153
|
+
effectGlow: "rgba(148, 163, 184, 0.3)",
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
interface VariantConfig {
|
|
159
|
+
effects: string[];
|
|
160
|
+
baseClasses: string;
|
|
161
|
+
overrideType: boolean;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const buttonVariantConfig: Record<ButtonVariant, VariantConfig> = {
|
|
165
|
+
clip1: {
|
|
166
|
+
effects: ["clip-top-left-bottom-right"],
|
|
167
|
+
baseClasses: "px-4 py-2 border",
|
|
168
|
+
overrideType: false,
|
|
169
|
+
},
|
|
170
|
+
clip2: {
|
|
171
|
+
effects: ["clip-top-right-bottom-left"],
|
|
172
|
+
baseClasses: "px-4 py-2 border",
|
|
173
|
+
overrideType: false,
|
|
174
|
+
},
|
|
175
|
+
ghost: {
|
|
176
|
+
effects: [],
|
|
177
|
+
baseClasses: "px-4 py-2 bg-transparent border-transparent hover:bg-current/10 hover:border-transparent",
|
|
178
|
+
overrideType: true,
|
|
179
|
+
},
|
|
180
|
+
link: {
|
|
181
|
+
effects: [],
|
|
182
|
+
baseClasses:
|
|
183
|
+
"px-0 py-0 bg-transparent border-transparent underline-offset-4 hover:underline hover:bg-transparent hover:border-transparent",
|
|
184
|
+
overrideType: true,
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
interface Ripple {
|
|
189
|
+
id: number;
|
|
190
|
+
x: number;
|
|
191
|
+
y: number;
|
|
192
|
+
size: number;
|
|
193
|
+
isFading: boolean;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const Button = (props: ButtonProps): JSX.Element => {
|
|
197
|
+
ensureButtonStyle();
|
|
198
|
+
|
|
199
|
+
const merged = mergeProps(
|
|
200
|
+
{
|
|
201
|
+
as: "button" as ButtonProps["as"],
|
|
202
|
+
intent: "primary" as ButtonIntent,
|
|
203
|
+
variant: "clip1" as ButtonVariant,
|
|
204
|
+
noRipple: false,
|
|
205
|
+
noScanline: false,
|
|
206
|
+
noGlow: false,
|
|
207
|
+
noPulse: false,
|
|
208
|
+
iconPosition: "left" as const,
|
|
209
|
+
},
|
|
210
|
+
props,
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
const [local, others] = splitProps(merged, [
|
|
214
|
+
"as",
|
|
215
|
+
"intent",
|
|
216
|
+
"variant",
|
|
217
|
+
"noRipple",
|
|
218
|
+
"noScanline",
|
|
219
|
+
"noGlow",
|
|
220
|
+
"noPulse",
|
|
221
|
+
"icon",
|
|
222
|
+
"iconPosition",
|
|
223
|
+
"class",
|
|
224
|
+
"children",
|
|
225
|
+
"onClick",
|
|
226
|
+
"disabled",
|
|
227
|
+
]);
|
|
228
|
+
|
|
229
|
+
const [ripples, setRipples] = createSignal<Ripple[]>([]);
|
|
230
|
+
|
|
231
|
+
let elementRef: HTMLElement | undefined;
|
|
232
|
+
let mouseDownTime = 0;
|
|
233
|
+
|
|
234
|
+
const isIconOnly = createMemo(() => !local.children && local.icon);
|
|
235
|
+
const intentConfig = createMemo(
|
|
236
|
+
() => buttonIntentConfig[local.intent as ButtonIntent] || buttonIntentConfig.primary,
|
|
237
|
+
);
|
|
238
|
+
const variantConfig = createMemo(
|
|
239
|
+
() => buttonVariantConfig[local.variant as ButtonVariant] || buttonVariantConfig.clip1,
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
const hasRipple = createMemo(() => {
|
|
243
|
+
if (local.noRipple) return false;
|
|
244
|
+
if (local.variant === "link") return false;
|
|
245
|
+
return true;
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
const activeEffects = createMemo(() => {
|
|
249
|
+
const effects: string[] = [...variantConfig().effects];
|
|
250
|
+
if (!local.noScanline) effects.push("scanline");
|
|
251
|
+
return effects;
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
const effectClasses = createMemo(() =>
|
|
255
|
+
activeEffects()
|
|
256
|
+
.map((effect) => {
|
|
257
|
+
const effectMap: Record<string, string> = {
|
|
258
|
+
scanline: styles["ks-hud-scan-line"],
|
|
259
|
+
"clip-top-left-bottom-right": styles["ks-hud-clip-top-left-bottom-right"],
|
|
260
|
+
"clip-top-right-bottom-left": styles["ks-hud-clip-top-right-bottom-left"],
|
|
261
|
+
"clip-minimal-top-left-bottom-right": styles["ks-hud-clip-minimal-top-left-bottom-right"],
|
|
262
|
+
"clip-minimal-top-right-bottom-left": styles["ks-hud-clip-minimal-top-right-bottom-left"],
|
|
263
|
+
glow: styles["ks-hud-glow"],
|
|
264
|
+
pulse: styles["ks-hud-pulse"],
|
|
265
|
+
};
|
|
266
|
+
return effectMap[effect];
|
|
267
|
+
})
|
|
268
|
+
.filter(Boolean),
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
const customProperties = createMemo(() => {
|
|
272
|
+
const colors = intentConfig().colors;
|
|
273
|
+
return {
|
|
274
|
+
"--ks-effect-color": colors.effect,
|
|
275
|
+
"--ks-effect-bg": colors.effectBg,
|
|
276
|
+
"--ks-effect-border": colors.effectBorder,
|
|
277
|
+
"--ks-effect-bg-hover": colors.effect.replace("0.1", "0.2"),
|
|
278
|
+
"--ks-effect-border-hover": colors.effect.replace("0.4", "0.6"),
|
|
279
|
+
"--ks-effect-glow": colors.effectGlow,
|
|
280
|
+
"--ks-effect-glow-hover": colors.effectGlow.replace("0.3", "0.5"),
|
|
281
|
+
"--ks-effect-pulse-bg": colors.effectBg,
|
|
282
|
+
"--ks-effect-pulse-bg-mid": colors.effect.replace("0.4", "0.2"),
|
|
283
|
+
"--ks-effect-pulse-glow": colors.effectGlow.replace("0.3", "0.2"),
|
|
284
|
+
"--ks-effect-pulse-glow-mid": colors.effectGlow,
|
|
285
|
+
"--ks-ripple-color": colors.ripple,
|
|
286
|
+
} as JSX.CSSProperties;
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
const classes = createMemo(() => {
|
|
290
|
+
const coreClasses =
|
|
291
|
+
"select-none inline-flex items-center justify-center gap-2 font-medium transition-all duration-200 focus:outline-none text-sm rounded";
|
|
292
|
+
const textColor = intentConfig().textColor;
|
|
293
|
+
const backgroundClasses = variantConfig().overrideType
|
|
294
|
+
? ""
|
|
295
|
+
: `${intentConfig().background} ${intentConfig().hover}`;
|
|
296
|
+
const variantClasses = variantConfig().baseClasses;
|
|
297
|
+
|
|
298
|
+
return cn(
|
|
299
|
+
coreClasses,
|
|
300
|
+
textColor,
|
|
301
|
+
backgroundClasses,
|
|
302
|
+
variantClasses,
|
|
303
|
+
local.class,
|
|
304
|
+
hasRipple() && styles["ks-btn-ripple"],
|
|
305
|
+
!hasRipple() && styles["ks-interactive"],
|
|
306
|
+
isIconOnly() && "aspect-square !p-2",
|
|
307
|
+
...effectClasses(),
|
|
308
|
+
local.disabled && "opacity-50 cursor-not-allowed",
|
|
309
|
+
);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
const handleMouseDown = (event: MouseEvent) => {
|
|
313
|
+
if (local.disabled) return;
|
|
314
|
+
if (hasRipple() && elementRef) {
|
|
315
|
+
mouseDownTime = Date.now();
|
|
316
|
+
const rect = elementRef.getBoundingClientRect();
|
|
317
|
+
const x = event.clientX - rect.left;
|
|
318
|
+
const y = event.clientY - rect.top;
|
|
319
|
+
const size = Math.max(rect.width, rect.height) * 2;
|
|
320
|
+
const rippleId = Date.now() + Math.random();
|
|
321
|
+
setRipples((prev) => [...prev, { id: rippleId, x, y, size, isFading: false }]);
|
|
322
|
+
}
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
const handleMouseUp = () => {
|
|
326
|
+
if (!hasRipple() || local.disabled) return;
|
|
327
|
+
const timeSinceMouseDown = Date.now() - mouseDownTime;
|
|
328
|
+
const delayBeforeFade = Math.max(0, 400 - timeSinceMouseDown);
|
|
329
|
+
setTimeout(() => {
|
|
330
|
+
setRipples((prev) => prev.map((r) => ({ ...r, isFading: true })));
|
|
331
|
+
setTimeout(() => setRipples([]), 400);
|
|
332
|
+
}, delayBeforeFade);
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
const handleMouseLeave = () => {
|
|
336
|
+
if (!local.disabled) handleMouseUp();
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
const handleClick = (event: MouseEvent) => {
|
|
340
|
+
if (local.onClick && !local.disabled) local.onClick(event);
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
const renderContent = createMemo(() => {
|
|
344
|
+
const IconComponent = local.icon;
|
|
345
|
+
if (isIconOnly() && IconComponent) return <IconComponent />;
|
|
346
|
+
if (IconComponent && local.children) {
|
|
347
|
+
const icon = <IconComponent />;
|
|
348
|
+
return local.iconPosition === "left" ? (
|
|
349
|
+
<>
|
|
350
|
+
{icon}
|
|
351
|
+
{local.children}
|
|
352
|
+
</>
|
|
353
|
+
) : (
|
|
354
|
+
<>
|
|
355
|
+
{local.children}
|
|
356
|
+
{icon}
|
|
357
|
+
</>
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
if (IconComponent) return <IconComponent />;
|
|
361
|
+
return local.children;
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
onCleanup(() => setRipples([]));
|
|
365
|
+
|
|
366
|
+
return (
|
|
367
|
+
<Dynamic
|
|
368
|
+
component={local.as}
|
|
369
|
+
ref={(el: HTMLElement) => {
|
|
370
|
+
elementRef = el;
|
|
371
|
+
const maybeRef = (others as { ref?: unknown }).ref;
|
|
372
|
+
if (typeof maybeRef === "function") (maybeRef as (e: HTMLElement) => void)(el);
|
|
373
|
+
}}
|
|
374
|
+
class={classes()}
|
|
375
|
+
style={customProperties()}
|
|
376
|
+
onMouseDown={handleMouseDown}
|
|
377
|
+
onMouseUp={handleMouseUp}
|
|
378
|
+
onMouseLeave={handleMouseLeave}
|
|
379
|
+
onClick={handleClick}
|
|
380
|
+
disabled={local.disabled}
|
|
381
|
+
{...others}
|
|
382
|
+
>
|
|
383
|
+
{renderContent()}
|
|
384
|
+
<Show when={hasRipple()}>
|
|
385
|
+
<For each={ripples()}>
|
|
386
|
+
{(ripple) => (
|
|
387
|
+
<span
|
|
388
|
+
class={cn(styles["ks-btn-ripple-effect"], ripple.isFading && styles["ks-btn-ripple-fade"])}
|
|
389
|
+
style={{
|
|
390
|
+
left: `${ripple.x - ripple.size / 2}px`,
|
|
391
|
+
top: `${ripple.y - ripple.size / 2}px`,
|
|
392
|
+
width: `${ripple.size}px`,
|
|
393
|
+
height: `${ripple.size}px`,
|
|
394
|
+
"background-color": "var(--ks-ripple-color, rgba(255, 255, 255, 0.3))",
|
|
395
|
+
}}
|
|
396
|
+
/>
|
|
397
|
+
)}
|
|
398
|
+
</For>
|
|
399
|
+
</Show>
|
|
400
|
+
</Dynamic>
|
|
401
|
+
);
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
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
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { Show, type JSX } from "solid-js";
|
|
2
|
+
|
|
3
|
+
/** Horizontal alignment for the whole heading block. */
|
|
4
|
+
export type SectionHeadingAlign = "left" | "center" | "right";
|
|
5
|
+
|
|
6
|
+
/** Heading level rendered for the title element. */
|
|
7
|
+
export type SectionHeadingLevel = "h1" | "h2" | "h3";
|
|
8
|
+
|
|
9
|
+
export interface SectionHeadingProps {
|
|
10
|
+
/**
|
|
11
|
+
* Title content. Accept JSX so a caller can apply its own brand styling
|
|
12
|
+
* (e.g. a gradient span) on part of the title without this component
|
|
13
|
+
* carrying any site-specific CSS.
|
|
14
|
+
*/
|
|
15
|
+
title: JSX.Element;
|
|
16
|
+
/** Small uppercase, wide-tracked label above the title (the "eyebrow"). */
|
|
17
|
+
kicker?: string;
|
|
18
|
+
/** Supporting copy below the title. Accepts JSX for richer content. */
|
|
19
|
+
subtitle?: JSX.Element;
|
|
20
|
+
/**
|
|
21
|
+
* Show a short underline accent bar below the title. The bar inherits the
|
|
22
|
+
* accent color; override with `accentClass`.
|
|
23
|
+
*/
|
|
24
|
+
accent?: boolean;
|
|
25
|
+
/** Tailwind class controlling the accent bar color. */
|
|
26
|
+
accentClass?: string;
|
|
27
|
+
/** Tailwind class controlling the kicker color. */
|
|
28
|
+
kickerClass?: string;
|
|
29
|
+
/** Tailwind class controlling the title color. */
|
|
30
|
+
titleClass?: string;
|
|
31
|
+
/** Tailwind class controlling the subtitle color. */
|
|
32
|
+
subtitleClass?: string;
|
|
33
|
+
/** Block alignment. Defaults to "left". */
|
|
34
|
+
align?: SectionHeadingAlign;
|
|
35
|
+
/** Title element tag. Defaults to "h2". */
|
|
36
|
+
as?: SectionHeadingLevel;
|
|
37
|
+
/** Extra classes on the outer wrapper. */
|
|
38
|
+
class?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const ALIGN_WRAP: Record<SectionHeadingAlign, string> = {
|
|
42
|
+
left: "text-left items-start",
|
|
43
|
+
center: "text-center items-center",
|
|
44
|
+
right: "text-right items-end",
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// Per-level title sizing. Domain-free defaults; callers override via titleClass.
|
|
48
|
+
const TITLE_SIZE: Record<SectionHeadingLevel, string> = {
|
|
49
|
+
h1: "text-3xl md:text-4xl lg:text-6xl font-bold tracking-tight",
|
|
50
|
+
h2: "text-2xl md:text-3xl lg:text-4xl font-bold",
|
|
51
|
+
h3: "text-xl md:text-2xl font-bold",
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A recurring section header block: an optional uppercase kicker, a title
|
|
56
|
+
* (any JSX, so the caller can apply brand-specific styling), an optional
|
|
57
|
+
* underline accent bar, and optional subtitle copy.
|
|
58
|
+
*
|
|
59
|
+
* Presentational only. No site copy, no domain coupling — every piece of text
|
|
60
|
+
* and color comes from props.
|
|
61
|
+
*/
|
|
62
|
+
export default function SectionHeading(props: SectionHeadingProps): JSX.Element {
|
|
63
|
+
const align = () => props.align ?? "left";
|
|
64
|
+
const level = () => props.as ?? "h2";
|
|
65
|
+
|
|
66
|
+
const Title = (titleProps: { class: string; children: JSX.Element }): JSX.Element => {
|
|
67
|
+
const tag = level();
|
|
68
|
+
if (tag === "h1") return <h1 class={titleProps.class}>{titleProps.children}</h1>;
|
|
69
|
+
if (tag === "h3") return <h3 class={titleProps.class}>{titleProps.children}</h3>;
|
|
70
|
+
return <h2 class={titleProps.class}>{titleProps.children}</h2>;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<div class={`flex flex-col ${ALIGN_WRAP[align()]} ${props.class ?? ""}`}>
|
|
75
|
+
<Show when={props.kicker}>
|
|
76
|
+
<div
|
|
77
|
+
class={`text-xs font-bold tracking-[0.3em] uppercase mb-2 ${
|
|
78
|
+
props.kickerClass ?? "text-amber-500"
|
|
79
|
+
}`}
|
|
80
|
+
>
|
|
81
|
+
{props.kicker}
|
|
82
|
+
</div>
|
|
83
|
+
</Show>
|
|
84
|
+
|
|
85
|
+
<Title class={`${TITLE_SIZE[level()]} ${props.titleClass ?? "text-white"}`}>
|
|
86
|
+
{props.title}
|
|
87
|
+
</Title>
|
|
88
|
+
|
|
89
|
+
<Show when={props.accent}>
|
|
90
|
+
<div class={`w-20 h-1 mt-4 rounded-full ${props.accentClass ?? "bg-amber-500"}`} />
|
|
91
|
+
</Show>
|
|
92
|
+
|
|
93
|
+
<Show when={props.subtitle}>
|
|
94
|
+
<p class={`mt-4 text-base md:text-lg max-w-2xl ${props.subtitleClass ?? "text-zinc-400"}`}>
|
|
95
|
+
{props.subtitle}
|
|
96
|
+
</p>
|
|
97
|
+
</Show>
|
|
98
|
+
</div>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { For, type Component, type JSX } from "solid-js";
|
|
2
|
+
|
|
3
|
+
/** Icon component shape: any component that accepts a numeric `size`
|
|
4
|
+
* (lucide-solid icons satisfy this, as does any custom SVG component). */
|
|
5
|
+
export type SocialIcon = Component<{ size?: number }>;
|
|
6
|
+
|
|
7
|
+
export interface SocialLink {
|
|
8
|
+
/** Destination URL. Opened in a new tab with rel="noopener noreferrer". */
|
|
9
|
+
href: string;
|
|
10
|
+
/** Icon component rendered inside the button (e.g. a lucide-solid icon). */
|
|
11
|
+
icon: SocialIcon;
|
|
12
|
+
/** Accessible label, used for `aria-label` and `title`. */
|
|
13
|
+
label: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Button outline shape. `round` is a full circle; `clip` cuts the
|
|
17
|
+
* top-right corner with a clip-path (the angular brand variant). */
|
|
18
|
+
export type SocialLinksShape = "round" | "clip";
|
|
19
|
+
|
|
20
|
+
export interface SocialLinksBarProps {
|
|
21
|
+
/** The links to render. The caller owns the URLs and icons; nothing
|
|
22
|
+
* domain-specific lives in the component. */
|
|
23
|
+
links: SocialLink[];
|
|
24
|
+
/** Button outline shape. Defaults to `round`. */
|
|
25
|
+
shape?: SocialLinksShape;
|
|
26
|
+
/** Pixel size of each square button. Defaults to 40 (`clip` defaults to 48). */
|
|
27
|
+
buttonSize?: number;
|
|
28
|
+
/** Pixel size of the icon. Defaults to ~45% of the button size. */
|
|
29
|
+
iconSize?: number;
|
|
30
|
+
/** Extra classes on the wrapping `<nav>`. */
|
|
31
|
+
class?: string;
|
|
32
|
+
testId?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Runtime-injected clip-path utility. ksui ships no sidecar CSS (the package
|
|
36
|
+
// exports only ./src), so the one rule the `clip` shape needs is emitted once
|
|
37
|
+
// as a <style> tag rather than imported from a sibling .css file.
|
|
38
|
+
const CLIP_STYLE_ID = "ksui-social-clip-style";
|
|
39
|
+
const CLIP_STYLE = `.ksui-social-clip{clip-path:polygon(0 0,calc(100% - 8px) 0,100% 8px,100% 100%,0 100%);}`;
|
|
40
|
+
|
|
41
|
+
// Inject the clip-path rule once per document, matching the SSR-safe
|
|
42
|
+
// getElementById-dedupe pattern used by Button/ThemeToggle. A module-level
|
|
43
|
+
// boolean would be non-reentrant across SSR requests (the worker reuses the
|
|
44
|
+
// module), so the style must be keyed off the document, not module state.
|
|
45
|
+
function ensureSocialClipStyle(): void {
|
|
46
|
+
if (typeof document === "undefined") return;
|
|
47
|
+
if (document.getElementById(CLIP_STYLE_ID)) return;
|
|
48
|
+
const el = document.createElement("style");
|
|
49
|
+
el.id = CLIP_STYLE_ID;
|
|
50
|
+
el.textContent = CLIP_STYLE;
|
|
51
|
+
document.head.appendChild(el);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A horizontal row of accessible, round (or clip-cornered) icon buttons that
|
|
56
|
+
* link out to external profiles. Each button opens its href in a new tab with
|
|
57
|
+
* a safe `rel`, carries an `aria-label`, and renders the caller-supplied icon.
|
|
58
|
+
*
|
|
59
|
+
* Domain-free: the specific URLs, icons, and labels all come from `links`.
|
|
60
|
+
*/
|
|
61
|
+
export default function SocialLinksBar(props: SocialLinksBarProps): JSX.Element {
|
|
62
|
+
ensureSocialClipStyle();
|
|
63
|
+
const shape = () => props.shape ?? "round";
|
|
64
|
+
const btn = () => props.buttonSize ?? (shape() === "clip" ? 48 : 40);
|
|
65
|
+
const icon = () => props.iconSize ?? Math.round(btn() * 0.45);
|
|
66
|
+
const shapeClass = () =>
|
|
67
|
+
shape() === "clip" ? "ksui-social-clip" : "rounded-full";
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<nav
|
|
71
|
+
data-testid={props.testId}
|
|
72
|
+
class={`flex gap-4 ${props.class ?? ""}`}
|
|
73
|
+
aria-label="Social links"
|
|
74
|
+
>
|
|
75
|
+
<For each={props.links}>
|
|
76
|
+
{(link) => (
|
|
77
|
+
<a
|
|
78
|
+
href={link.href}
|
|
79
|
+
target="_blank"
|
|
80
|
+
rel="noopener noreferrer"
|
|
81
|
+
title={link.label}
|
|
82
|
+
aria-label={link.label}
|
|
83
|
+
class={`flex items-center justify-center bg-zinc-800 text-zinc-400 transition-all hover:bg-amber-500 hover:text-black ${shapeClass()}`}
|
|
84
|
+
style={{ width: `${btn()}px`, height: `${btn()}px` }}
|
|
85
|
+
>
|
|
86
|
+
{link.icon({ size: icon() })}
|
|
87
|
+
</a>
|
|
88
|
+
)}
|
|
89
|
+
</For>
|
|
90
|
+
</nav>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { Show, type JSX } from "solid-js";
|
|
2
|
+
|
|
3
|
+
export type StatusIndicatorTone = "success" | "neutral" | "warning" | "danger" | "info";
|
|
4
|
+
|
|
5
|
+
interface ToneClass {
|
|
6
|
+
/** Filled dot color. */
|
|
7
|
+
dot: string;
|
|
8
|
+
/** Label text color. */
|
|
9
|
+
text: string;
|
|
10
|
+
/** Box-shadow glow used when the dot pulses (an arbitrary Tailwind value). */
|
|
11
|
+
glow: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Module-private tone palette. The caller maps its own availability/state
|
|
15
|
+
// (online/offline, open/closed, healthy/degraded) to one of these tones and
|
|
16
|
+
// passes a plain label; nothing domain-specific leaks into this atom.
|
|
17
|
+
const TONE_CLASS: Record<StatusIndicatorTone, ToneClass> = {
|
|
18
|
+
success: {
|
|
19
|
+
dot: "bg-emerald-400",
|
|
20
|
+
text: "text-white",
|
|
21
|
+
glow: "shadow-[0_0_10px_rgba(52,211,153,0.6)]",
|
|
22
|
+
},
|
|
23
|
+
neutral: {
|
|
24
|
+
dot: "bg-zinc-400",
|
|
25
|
+
text: "text-zinc-400",
|
|
26
|
+
glow: "shadow-[0_0_10px_rgba(161,161,170,0.5)]",
|
|
27
|
+
},
|
|
28
|
+
warning: {
|
|
29
|
+
dot: "bg-amber-400",
|
|
30
|
+
text: "text-amber-300",
|
|
31
|
+
glow: "shadow-[0_0_10px_rgba(251,191,36,0.6)]",
|
|
32
|
+
},
|
|
33
|
+
danger: {
|
|
34
|
+
dot: "bg-red-400",
|
|
35
|
+
text: "text-red-300",
|
|
36
|
+
glow: "shadow-[0_0_10px_rgba(248,113,113,0.6)]",
|
|
37
|
+
},
|
|
38
|
+
info: {
|
|
39
|
+
dot: "bg-blue-400",
|
|
40
|
+
text: "text-blue-300",
|
|
41
|
+
glow: "shadow-[0_0_10px_rgba(96,165,250,0.6)]",
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export interface StatusIndicatorProps {
|
|
46
|
+
/** Text shown beside the dot (the caller's own label). */
|
|
47
|
+
label: string;
|
|
48
|
+
/** Domain-free tone selector. The caller maps its enum to one of these. */
|
|
49
|
+
tone?: StatusIndicatorTone;
|
|
50
|
+
/**
|
|
51
|
+
* Convenience boolean: when provided, overrides `tone` with `success` (true)
|
|
52
|
+
* or `danger` (false). Lets a caller bind a plain availability flag.
|
|
53
|
+
*/
|
|
54
|
+
online?: boolean;
|
|
55
|
+
/** Animate the dot with a pulse + glow (good for a "live" indicator). */
|
|
56
|
+
pulse?: boolean;
|
|
57
|
+
/** Render the label uppercase with wide tracking (the marquee/chip style). */
|
|
58
|
+
uppercase?: boolean;
|
|
59
|
+
/** Optional small caption above the label (e.g. a category line). */
|
|
60
|
+
caption?: string;
|
|
61
|
+
/** Caption text color class; defaults to an amber accent. */
|
|
62
|
+
captionClass?: string;
|
|
63
|
+
/** Extra classes on the wrapper. */
|
|
64
|
+
class?: string;
|
|
65
|
+
testId?: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Single availability-indicator atom. A filled, optionally pulsing colored dot
|
|
69
|
+
// next to a label. Distinct from StatusPill (a bordered tinted text chip) — this
|
|
70
|
+
// is the animated "live status" dot. The caller owns the domain → tone mapping
|
|
71
|
+
// and the label text.
|
|
72
|
+
export default function StatusIndicator(props: StatusIndicatorProps): JSX.Element {
|
|
73
|
+
const tone = (): StatusIndicatorTone =>
|
|
74
|
+
props.online === undefined ? (props.tone ?? "neutral") : props.online ? "success" : "danger";
|
|
75
|
+
const tc = () => TONE_CLASS[tone()];
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<div
|
|
79
|
+
data-testid={props.testId}
|
|
80
|
+
class={`flex items-center gap-3 ${props.class ?? ""}`}
|
|
81
|
+
>
|
|
82
|
+
<span
|
|
83
|
+
aria-hidden="true"
|
|
84
|
+
class={`h-3 w-3 shrink-0 rounded-full ${tc().dot} ${
|
|
85
|
+
props.pulse ? `animate-pulse ${tc().glow}` : ""
|
|
86
|
+
}`}
|
|
87
|
+
/>
|
|
88
|
+
<div>
|
|
89
|
+
<Show when={props.caption}>
|
|
90
|
+
<div
|
|
91
|
+
class={`text-xs font-bold uppercase tracking-widest ${
|
|
92
|
+
props.captionClass ?? "text-amber-400"
|
|
93
|
+
}`}
|
|
94
|
+
>
|
|
95
|
+
{props.caption}
|
|
96
|
+
</div>
|
|
97
|
+
</Show>
|
|
98
|
+
<div
|
|
99
|
+
class={`${tc().text} ${
|
|
100
|
+
props.uppercase
|
|
101
|
+
? "text-xs font-bold uppercase tracking-widest"
|
|
102
|
+
: "text-sm font-bold"
|
|
103
|
+
}`}
|
|
104
|
+
>
|
|
105
|
+
{props.label}
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Source: kahitsan-web src/components/Header.tsx (ThemeToggle, lines 30-45)
|
|
2
|
+
// + its CSS in src/assets/css/button.css (.ks-theme-toggle*).
|
|
3
|
+
// Extracted as a domain-free, controlled primitive: the theme state is lifted
|
|
4
|
+
// to props (`value` + `onToggle`) so it carries no dependency on kahitsan's
|
|
5
|
+
// ~/lib/theme. Only solid-js + lucide-solid.
|
|
6
|
+
//
|
|
7
|
+
// ksui ships no sibling .css and no sidecar CSS in the published package, so
|
|
8
|
+
// the custom track/thumb/icon styles (transitions, the sliding ::after thumb)
|
|
9
|
+
// are injected once at runtime via a <style> tag — the same pattern ProgressBar
|
|
10
|
+
// uses — and referenced by plain, unscoped class names.
|
|
11
|
+
|
|
12
|
+
import type { JSX } from "solid-js";
|
|
13
|
+
import { splitProps } from "solid-js";
|
|
14
|
+
import Sun from "lucide-solid/icons/sun";
|
|
15
|
+
import Moon from "lucide-solid/icons/moon";
|
|
16
|
+
|
|
17
|
+
const THEME_TOGGLE_STYLE_ID = "ks-theme-toggle-inline-style";
|
|
18
|
+
const THEME_TOGGLE_CSS = `
|
|
19
|
+
.ks-theme-toggle { background: none; border: none; padding: 2px; cursor: pointer; display: flex; align-items: center; }
|
|
20
|
+
.ks-theme-toggle-track { position: relative; display: flex; align-items: center; justify-content: space-between; width: 52px; height: 28px; border-radius: 14px; background-color: #3f3f46; padding: 0 2px; transition: background-color 0.3s ease; }
|
|
21
|
+
.ks-theme-toggle-track[data-active] { background-color: #d3c5ac; }
|
|
22
|
+
.ks-theme-toggle-icon { position: relative; z-index: 2; display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; border-radius: 50%; pointer-events: none; transition: color 0.3s ease; }
|
|
23
|
+
.ks-theme-toggle-track::after { content: ''; position: absolute; left: 2px; top: 2px; width: 24px; height: 24px; border-radius: 50%; background-color: #52525b; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); transition: transform 0.3s ease, background-color 0.3s ease; z-index: 1; }
|
|
24
|
+
.ks-theme-toggle-track[data-active]::after { transform: translateX(24px); background-color: #ffffff; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15); }
|
|
25
|
+
.ks-theme-toggle-icon-moon { color: #fbbf24; }
|
|
26
|
+
.ks-theme-toggle-icon-sun { color: rgba(255, 255, 255, 0.3); }
|
|
27
|
+
.ks-theme-toggle-track[data-active] .ks-theme-toggle-icon-moon { color: rgba(120, 90, 0, 0.3); }
|
|
28
|
+
.ks-theme-toggle-track[data-active] .ks-theme-toggle-icon-sun { color: #785a00; }
|
|
29
|
+
.ks-theme-toggle:hover .ks-theme-toggle-track { background-color: #52525b; }
|
|
30
|
+
.ks-theme-toggle:hover .ks-theme-toggle-track[data-active] { background-color: #c9b99a; }
|
|
31
|
+
@media (prefers-reduced-motion: reduce) { .ks-theme-toggle-track, .ks-theme-toggle-track::after, .ks-theme-toggle-icon { transition: none; } }
|
|
32
|
+
`;
|
|
33
|
+
|
|
34
|
+
function ensureThemeToggleStyle(): void {
|
|
35
|
+
if (typeof document === "undefined") return;
|
|
36
|
+
if (document.getElementById(THEME_TOGGLE_STYLE_ID)) return;
|
|
37
|
+
const el = document.createElement("style");
|
|
38
|
+
el.id = THEME_TOGGLE_STYLE_ID;
|
|
39
|
+
el.textContent = THEME_TOGGLE_CSS;
|
|
40
|
+
document.head.appendChild(el);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type ThemeToggleValue = "dark" | "light";
|
|
44
|
+
|
|
45
|
+
export interface ThemeToggleProps
|
|
46
|
+
extends Omit<JSX.ButtonHTMLAttributes<HTMLButtonElement>, "value" | "onToggle"> {
|
|
47
|
+
/** Current theme. `"light"` slides the thumb right and activates the sun. */
|
|
48
|
+
value: ThemeToggleValue;
|
|
49
|
+
/** Called with the opposite theme when the toggle is clicked. */
|
|
50
|
+
onToggle: (next: ThemeToggleValue) => void;
|
|
51
|
+
/**
|
|
52
|
+
* Accessible label. Receives the theme the click will switch TO so the
|
|
53
|
+
* default reads e.g. "Switch to light mode". Override for non-English UIs.
|
|
54
|
+
*/
|
|
55
|
+
ariaLabel?: (next: ThemeToggleValue) => string;
|
|
56
|
+
/** Optional test hook (data-testid). */
|
|
57
|
+
testId?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function cn(...classes: Array<string | undefined | null | false>): string {
|
|
61
|
+
return classes.filter(Boolean).join(" ");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const defaultAriaLabel = (next: ThemeToggleValue): string => `Switch to ${next} mode`;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* A controlled sliding sun/moon track toggle. Domain-free: it owns no theme
|
|
68
|
+
* state and applies no theme — it renders `value` and reports the intended
|
|
69
|
+
* next value through `onToggle`. The parent owns the theme source of truth.
|
|
70
|
+
*/
|
|
71
|
+
export default function ThemeToggle(props: ThemeToggleProps): JSX.Element {
|
|
72
|
+
ensureThemeToggleStyle();
|
|
73
|
+
|
|
74
|
+
const [local, rest] = splitProps(props, [
|
|
75
|
+
"value",
|
|
76
|
+
"onToggle",
|
|
77
|
+
"ariaLabel",
|
|
78
|
+
"testId",
|
|
79
|
+
"class",
|
|
80
|
+
"onClick",
|
|
81
|
+
]);
|
|
82
|
+
|
|
83
|
+
const isLight = (): boolean => local.value === "light";
|
|
84
|
+
const next = (): ThemeToggleValue => (isLight() ? "dark" : "light");
|
|
85
|
+
const label = (): string => (local.ariaLabel ?? defaultAriaLabel)(next());
|
|
86
|
+
|
|
87
|
+
const handleClick: JSX.EventHandler<HTMLButtonElement, MouseEvent> = (event) => {
|
|
88
|
+
local.onToggle(next());
|
|
89
|
+
if (typeof local.onClick === "function") local.onClick(event);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<button
|
|
94
|
+
type="button"
|
|
95
|
+
class={cn("ks-theme-toggle", local.class)}
|
|
96
|
+
aria-label={label()}
|
|
97
|
+
title={label()}
|
|
98
|
+
data-testid={local.testId}
|
|
99
|
+
onClick={handleClick}
|
|
100
|
+
{...rest}
|
|
101
|
+
>
|
|
102
|
+
<span class="ks-theme-toggle-track" data-active={isLight() ? "" : undefined}>
|
|
103
|
+
<span class="ks-theme-toggle-icon ks-theme-toggle-icon-moon">
|
|
104
|
+
<Moon size={12} />
|
|
105
|
+
</span>
|
|
106
|
+
<span class="ks-theme-toggle-icon ks-theme-toggle-icon-sun">
|
|
107
|
+
<Sun size={12} />
|
|
108
|
+
</span>
|
|
109
|
+
</span>
|
|
110
|
+
</button>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// Centered 404 / empty-state panel: an optional logo slot, a large display
|
|
2
|
+
// title, a heading, a message, and a default "go back" action button. It is a
|
|
3
|
+
// fully prop-driven, domain-free primitive — every piece of copy defaults to a
|
|
4
|
+
// generic 404 string but is overridable, and nothing about any specific site
|
|
5
|
+
// is hard-coded.
|
|
6
|
+
//
|
|
7
|
+
// Unlike the host-owned Button, this is a standalone primitive: it imports only
|
|
8
|
+
// solid-js and lucide-solid, never `@kserp/host-ui` or a router. The default
|
|
9
|
+
// action renders a plain, self-styled button. Navigation is left to the caller
|
|
10
|
+
// via `onButtonClick` (or `href`, which renders an anchor instead). Callers who
|
|
11
|
+
// want the host Button can pass it through the `action` slot, which fully
|
|
12
|
+
// replaces the built-in button.
|
|
13
|
+
|
|
14
|
+
import type { JSX } from "solid-js";
|
|
15
|
+
import { Show, splitProps } from "solid-js";
|
|
16
|
+
import { ArrowLeft } from "lucide-solid";
|
|
17
|
+
|
|
18
|
+
export interface NotFoundProps
|
|
19
|
+
extends Omit<JSX.HTMLAttributes<HTMLDivElement>, "title"> {
|
|
20
|
+
/** Large display title. Defaults to "404". Pass "" to hide it entirely. */
|
|
21
|
+
title?: string;
|
|
22
|
+
/** Secondary heading under the title. Defaults to "Page Not Found". */
|
|
23
|
+
heading?: string;
|
|
24
|
+
/** Supporting message under the heading. */
|
|
25
|
+
message?: string;
|
|
26
|
+
/** Label for the default action button. Defaults to "Go Back Home". */
|
|
27
|
+
buttonText?: string;
|
|
28
|
+
/**
|
|
29
|
+
* When set, the default action renders as an anchor pointing here instead of
|
|
30
|
+
* a button. Ignored when `onButtonClick` or `action` is provided.
|
|
31
|
+
*/
|
|
32
|
+
href?: string;
|
|
33
|
+
/** Click handler for the default action button. */
|
|
34
|
+
onButtonClick?: () => void;
|
|
35
|
+
/** Optional element rendered above the title (e.g. a logo or icon). */
|
|
36
|
+
logo?: JSX.Element;
|
|
37
|
+
/** Hide the action entirely. */
|
|
38
|
+
hideButton?: boolean;
|
|
39
|
+
/**
|
|
40
|
+
* Replace the built-in action with a custom element (e.g. the host Button).
|
|
41
|
+
* When provided, `buttonText`, `href`, and `onButtonClick` are ignored.
|
|
42
|
+
*/
|
|
43
|
+
action?: JSX.Element;
|
|
44
|
+
/** Test id applied to the outer container. */
|
|
45
|
+
testId?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export default function NotFound(props: NotFoundProps): JSX.Element {
|
|
49
|
+
const [local, others] = splitProps(props, [
|
|
50
|
+
"title",
|
|
51
|
+
"heading",
|
|
52
|
+
"message",
|
|
53
|
+
"buttonText",
|
|
54
|
+
"href",
|
|
55
|
+
"onButtonClick",
|
|
56
|
+
"logo",
|
|
57
|
+
"hideButton",
|
|
58
|
+
"action",
|
|
59
|
+
"testId",
|
|
60
|
+
"class",
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
const showTitle = () => local.title !== "";
|
|
64
|
+
const buttonClass =
|
|
65
|
+
"inline-flex items-center justify-center gap-2 rounded-md border " +
|
|
66
|
+
"border-amber-600/60 bg-amber-600/20 px-5 py-2.5 text-sm font-semibold " +
|
|
67
|
+
"text-amber-400 transition-colors hover:border-amber-500 " +
|
|
68
|
+
"hover:bg-amber-600/30 focus:outline-none focus-visible:ring-2 " +
|
|
69
|
+
"focus-visible:ring-amber-500/60";
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<div
|
|
73
|
+
class={
|
|
74
|
+
"flex items-center justify-center min-h-screen" +
|
|
75
|
+
(local.class ? " " + local.class : "")
|
|
76
|
+
}
|
|
77
|
+
data-testid={local.testId}
|
|
78
|
+
{...others}
|
|
79
|
+
>
|
|
80
|
+
<div class="text-center">
|
|
81
|
+
<Show when={local.logo}>
|
|
82
|
+
<div class="flex items-center justify-center mx-auto mb-6">{local.logo}</div>
|
|
83
|
+
</Show>
|
|
84
|
+
|
|
85
|
+
<Show when={showTitle()}>
|
|
86
|
+
<h1 class="text-4xl md:text-6xl font-bold text-amber-500 mb-4">
|
|
87
|
+
{local.title || "404"}
|
|
88
|
+
</h1>
|
|
89
|
+
</Show>
|
|
90
|
+
|
|
91
|
+
<h2 class="text-xl md:text-2xl font-bold text-white mb-3">
|
|
92
|
+
{local.heading || "Page Not Found"}
|
|
93
|
+
</h2>
|
|
94
|
+
|
|
95
|
+
<p class="text-sm text-zinc-400 max-w-md mb-8">
|
|
96
|
+
{local.message ||
|
|
97
|
+
"The page you're looking for doesn't exist or has been moved."}
|
|
98
|
+
</p>
|
|
99
|
+
|
|
100
|
+
<Show when={!local.hideButton}>
|
|
101
|
+
<Show
|
|
102
|
+
when={local.action}
|
|
103
|
+
fallback={
|
|
104
|
+
<Show
|
|
105
|
+
when={local.href && !local.onButtonClick}
|
|
106
|
+
fallback={
|
|
107
|
+
<button
|
|
108
|
+
type="button"
|
|
109
|
+
class={buttonClass}
|
|
110
|
+
onClick={() => local.onButtonClick?.()}
|
|
111
|
+
>
|
|
112
|
+
<ArrowLeft size={16} aria-hidden="true" />
|
|
113
|
+
{local.buttonText || "Go Back Home"}
|
|
114
|
+
</button>
|
|
115
|
+
}
|
|
116
|
+
>
|
|
117
|
+
<a href={local.href} class={buttonClass}>
|
|
118
|
+
<ArrowLeft size={16} aria-hidden="true" />
|
|
119
|
+
{local.buttonText || "Go Back Home"}
|
|
120
|
+
</a>
|
|
121
|
+
</Show>
|
|
122
|
+
}
|
|
123
|
+
>
|
|
124
|
+
{local.action}
|
|
125
|
+
</Show>
|
|
126
|
+
</Show>
|
|
127
|
+
</div>
|
|
128
|
+
</div>
|
|
129
|
+
);
|
|
130
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -64,6 +64,13 @@ export { default as RadioCardGroup } from "./components/base/RadioCardGroup";
|
|
|
64
64
|
export { default as FormErrorBanner } from "./components/base/FormErrorBanner";
|
|
65
65
|
export { default as TagPill } from "./components/base/TagPill";
|
|
66
66
|
export { default as DateTile, type DateTileProps } from "./components/base/DateTile";
|
|
67
|
+
export { default as Button, type ButtonProps, type ButtonIntent, type ButtonVariant } from "./components/base/Button";
|
|
68
|
+
export { default as ThemeToggle, type ThemeToggleProps, type ThemeToggleValue } from "./components/base/ThemeToggle";
|
|
69
|
+
export { default as Dropdown, type DropdownProps, type DropdownItem, type DropdownItemStatus } from "./components/base/Dropdown";
|
|
70
|
+
export { default as SocialLinksBar, type SocialLinksBarProps, type SocialLink, type SocialIcon, type SocialLinksShape } from "./components/base/SocialLinksBar";
|
|
71
|
+
export { default as StatusIndicator, type StatusIndicatorProps, type StatusIndicatorTone } from "./components/base/StatusIndicator";
|
|
72
|
+
export { default as SectionHeading, type SectionHeadingProps, type SectionHeadingAlign, type SectionHeadingLevel } from "./components/base/SectionHeading";
|
|
73
|
+
export { default as EyebrowBadge, type EyebrowBadgeProps, type EyebrowTone, type EyebrowTracking } from "./components/base/EyebrowBadge";
|
|
67
74
|
|
|
68
75
|
// ---------------------------------------------------------------------------
|
|
69
76
|
// Composite components
|
|
@@ -97,6 +104,8 @@ export type { PayeeOption, PayeeKind } from "./components/composite/PayeePicker"
|
|
|
97
104
|
export { default as VoucherPicker, calculateDiscount } from "./components/composite/VoucherPicker";
|
|
98
105
|
export type { VoucherOption } from "./components/composite/VoucherPicker";
|
|
99
106
|
|
|
107
|
+
export { default as NotFound, type NotFoundProps } from "./components/composite/NotFound";
|
|
108
|
+
|
|
100
109
|
// ---------------------------------------------------------------------------
|
|
101
110
|
// Utils (not components)
|
|
102
111
|
// ---------------------------------------------------------------------------
|