@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 +1 -1
- package/src/components/base/Button.tsx +400 -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/components/composite/PayeePicker.tsx +362 -0
- package/src/index.ts +12 -0
|
@@ -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
|
+
}
|