@kitn.ai/ui 0.14.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/LICENSE +21 -0
- package/README.md +394 -0
- package/dist/bash-InADTalH.js +1 -0
- package/dist/core-AYMC6_lb.js +12 -0
- package/dist/css-M7EaDHN_.js +1 -0
- package/dist/custom-elements.json +4739 -0
- package/dist/engine-javascript-vq0WuIJl.js +141 -0
- package/dist/github-dark-dimmed-DUshB20C.js +1 -0
- package/dist/github-light-JYsPkUQd.js +1 -0
- package/dist/host-embed.d.ts +33 -0
- package/dist/html-CPZ3oZQ7.js +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/javascript-C25yR2R2.js +1 -0
- package/dist/json-DxJze_jm.js +1 -0
- package/dist/kitn-chat-provider.d.ts +2 -0
- package/dist/kitn-chat-provider.es.js +240 -0
- package/dist/kitn-chat.es.js +77 -0
- package/dist/llms/llms-full.txt +1018 -0
- package/dist/llms/llms.txt +104 -0
- package/dist/origin.d.ts +5 -0
- package/dist/provider-runtime.d.ts +27 -0
- package/dist/schemas/card-envelope.schema.json +14 -0
- package/dist/schemas/card-event.schema.json +12 -0
- package/dist/schemas/choice.schema.json +70 -0
- package/dist/schemas/confirm.schema.json +65 -0
- package/dist/schemas/embed.schema.json +65 -0
- package/dist/schemas/form.result.schema.json +7 -0
- package/dist/schemas/form.schema.json +33 -0
- package/dist/schemas/link.schema.json +56 -0
- package/dist/schemas/tasks.result.schema.json +16 -0
- package/dist/schemas/tasks.schema.json +78 -0
- package/dist/svelte--5p79yCD.js +1 -0
- package/dist/theme.tokens.css +176 -0
- package/dist/tsx-B8rCNbgL.js +1 -0
- package/dist/typescript-RycA9KXf.js +1 -0
- package/dist/validate.d.ts +4 -0
- package/dist/version.d.ts +4 -0
- package/dist/vue-BmIZj4XD.js +1 -0
- package/dist/wire.d.ts +53 -0
- package/frameworks/react/index.tsx +948 -0
- package/frameworks/react/runtime.tsx +94 -0
- package/llms-full.txt +1018 -0
- package/llms.txt +104 -0
- package/package.json +141 -0
- package/src/components/artifact.stories.tsx +138 -0
- package/src/components/artifact.tsx +743 -0
- package/src/components/attachments.stories.tsx +303 -0
- package/src/components/attachments.test.tsx +64 -0
- package/src/components/attachments.tsx +399 -0
- package/src/components/card-fallback.tsx +28 -0
- package/src/components/card-renderer.tsx +52 -0
- package/src/components/card.tsx +110 -0
- package/src/components/chain-of-thought.stories.tsx +211 -0
- package/src/components/chain-of-thought.tsx +139 -0
- package/src/components/chat-container.stories.tsx +187 -0
- package/src/components/chat-container.tsx +82 -0
- package/src/components/chat-scope-picker.tsx +47 -0
- package/src/components/chat-thread.test.tsx +58 -0
- package/src/components/chat-thread.tsx +249 -0
- package/src/components/checkpoint.stories.tsx +102 -0
- package/src/components/checkpoint.tsx +88 -0
- package/src/components/choice-card.tsx +569 -0
- package/src/components/code-block.stories.tsx +150 -0
- package/src/components/code-block.tsx +105 -0
- package/src/components/component-meta.json +3735 -0
- package/src/components/confirm-card.stories.tsx +74 -0
- package/src/components/confirm-card.tsx +315 -0
- package/src/components/context.stories.tsx +229 -0
- package/src/components/context.test.tsx +133 -0
- package/src/components/context.tsx +363 -0
- package/src/components/conversation-item.stories.tsx +125 -0
- package/src/components/conversation-item.tsx +16 -0
- package/src/components/conversation-list.stories.tsx +133 -0
- package/src/components/conversation-list.tsx +101 -0
- package/src/components/default-input.test.tsx +111 -0
- package/src/components/embed.tsx +196 -0
- package/src/components/empty.stories.tsx +434 -0
- package/src/components/empty.tsx +166 -0
- package/src/components/feedback-bar.stories.tsx +138 -0
- package/src/components/feedback-bar.test.tsx +103 -0
- package/src/components/feedback-bar.tsx +166 -0
- package/src/components/file-tree.stories.tsx +73 -0
- package/src/components/file-tree.tsx +383 -0
- package/src/components/file-upload.stories.tsx +156 -0
- package/src/components/file-upload.tsx +161 -0
- package/src/components/form-widgets.tsx +461 -0
- package/src/components/form.tsx +873 -0
- package/src/components/image.stories.tsx +89 -0
- package/src/components/image.tsx +67 -0
- package/src/components/link-preview.tsx +194 -0
- package/src/components/loader.stories.tsx +181 -0
- package/src/components/loader.tsx +333 -0
- package/src/components/markdown.stories.tsx +180 -0
- package/src/components/markdown.tsx +81 -0
- package/src/components/message-action-bar.test.tsx +128 -0
- package/src/components/message-narrow.stories.tsx +329 -0
- package/src/components/message-skills.stories.tsx +211 -0
- package/src/components/message-skills.tsx +36 -0
- package/src/components/message.stories.tsx +281 -0
- package/src/components/message.tsx +227 -0
- package/src/components/model-switcher.stories.tsx +123 -0
- package/src/components/model-switcher.test.tsx +65 -0
- package/src/components/model-switcher.tsx +87 -0
- package/src/components/prompt-input.stories.tsx +222 -0
- package/src/components/prompt-input.tsx +211 -0
- package/src/components/prompt-suggestion.stories.tsx +142 -0
- package/src/components/prompt-suggestion.tsx +115 -0
- package/src/components/reasoning.stories.tsx +140 -0
- package/src/components/reasoning.tsx +156 -0
- package/src/components/resizable.test.tsx +226 -0
- package/src/components/response-stream.tsx +103 -0
- package/src/components/scroll-button.stories.tsx +100 -0
- package/src/components/scroll-button.tsx +34 -0
- package/src/components/slash-command.stories.tsx +163 -0
- package/src/components/slash-command.tsx +232 -0
- package/src/components/source-list.test.tsx +152 -0
- package/src/components/source.stories.tsx +124 -0
- package/src/components/source.tsx +129 -0
- package/src/components/tasks-card.stories.tsx +78 -0
- package/src/components/tasks-card.tsx +432 -0
- package/src/components/text-shimmer.stories.tsx +87 -0
- package/src/components/text-shimmer.tsx +37 -0
- package/src/components/thinking-bar.stories.tsx +87 -0
- package/src/components/thinking-bar.tsx +50 -0
- package/src/components/tool.stories.tsx +153 -0
- package/src/components/tool.tsx +184 -0
- package/src/components/use-card-resolution.ts +28 -0
- package/src/components/voice-input.stories.tsx +83 -0
- package/src/components/voice-input.tsx +107 -0
- package/src/elements/artifact.stories.tsx +506 -0
- package/src/elements/artifact.tsx +137 -0
- package/src/elements/attachments.stories.tsx +190 -0
- package/src/elements/attachments.tsx +136 -0
- package/src/elements/card.stories.tsx +113 -0
- package/src/elements/card.tsx +40 -0
- package/src/elements/cards.stories.tsx +59 -0
- package/src/elements/cards.tsx +97 -0
- package/src/elements/catalog.stories.tsx +491 -0
- package/src/elements/chain-of-thought.declarative.test.tsx +153 -0
- package/src/elements/chain-of-thought.stories.tsx +113 -0
- package/src/elements/chain-of-thought.tsx +92 -0
- package/src/elements/chat-scope-picker.stories.tsx +73 -0
- package/src/elements/chat-scope-picker.tsx +36 -0
- package/src/elements/chat-types.ts +42 -0
- package/src/elements/chat-workspace.stories.tsx +288 -0
- package/src/elements/chat-workspace.tsx +161 -0
- package/src/elements/chat.stories.tsx +151 -0
- package/src/elements/chat.tsx +80 -0
- package/src/elements/checkpoint.stories.tsx +72 -0
- package/src/elements/checkpoint.tsx +47 -0
- package/src/elements/choice.stories.tsx +240 -0
- package/src/elements/choice.tsx +48 -0
- package/src/elements/code-block.stories.tsx +83 -0
- package/src/elements/code-block.tsx +42 -0
- package/src/elements/compiled.css +2 -0
- package/src/elements/composed-shell.stories.tsx +316 -0
- package/src/elements/confirm-card.stories.tsx +227 -0
- package/src/elements/confirm-card.tsx +51 -0
- package/src/elements/context-meter.stories.tsx +151 -0
- package/src/elements/context-meter.tsx +121 -0
- package/src/elements/conversation-list.declarative.test.tsx +137 -0
- package/src/elements/conversation-list.stories.tsx +243 -0
- package/src/elements/conversation-list.tsx +80 -0
- package/src/elements/css.ts +5 -0
- package/src/elements/default-input.tsx +247 -0
- package/src/elements/define.tsx +171 -0
- package/src/elements/element-meta.json +3314 -0
- package/src/elements/element-types.d.ts +710 -0
- package/src/elements/embed.stories.tsx +213 -0
- package/src/elements/embed.tsx +35 -0
- package/src/elements/empty.stories.tsx +111 -0
- package/src/elements/empty.tsx +29 -0
- package/src/elements/feedback-bar.stories.tsx +114 -0
- package/src/elements/feedback-bar.tsx +61 -0
- package/src/elements/file-tree.stories.tsx +134 -0
- package/src/elements/file-tree.tsx +52 -0
- package/src/elements/file-upload.stories.tsx +82 -0
- package/src/elements/file-upload.tsx +44 -0
- package/src/elements/form.stories.tsx +248 -0
- package/src/elements/form.tsx +43 -0
- package/src/elements/framework-usage.json +563 -0
- package/src/elements/image.stories.tsx +71 -0
- package/src/elements/image.tsx +32 -0
- package/src/elements/link-preview.stories.tsx +209 -0
- package/src/elements/link-preview.tsx +34 -0
- package/src/elements/loader.stories.tsx +88 -0
- package/src/elements/loader.tsx +25 -0
- package/src/elements/markdown.stories.tsx +76 -0
- package/src/elements/markdown.tsx +38 -0
- package/src/elements/message-skills.declarative.test.tsx +104 -0
- package/src/elements/message-skills.stories.tsx +114 -0
- package/src/elements/message-skills.tsx +85 -0
- package/src/elements/message.stories.tsx +218 -0
- package/src/elements/message.tsx +162 -0
- package/src/elements/model-switcher.declarative.test.tsx +130 -0
- package/src/elements/model-switcher.stories.tsx +198 -0
- package/src/elements/model-switcher.tsx +93 -0
- package/src/elements/popover.stories.tsx +80 -0
- package/src/elements/popover.tsx +59 -0
- package/src/elements/prompt-input-slash-command.test.tsx +128 -0
- package/src/elements/prompt-input-stoppable.test.tsx +88 -0
- package/src/elements/prompt-input-toolbar-actions.test.tsx +120 -0
- package/src/elements/prompt-input.stories.tsx +331 -0
- package/src/elements/prompt-input.tsx +185 -0
- package/src/elements/prompt-suggestions.declarative.test.tsx +164 -0
- package/src/elements/prompt-suggestions.stories.tsx +213 -0
- package/src/elements/prompt-suggestions.tsx +94 -0
- package/src/elements/reasoning.stories.tsx +77 -0
- package/src/elements/reasoning.tsx +50 -0
- package/src/elements/register.ts +56 -0
- package/src/elements/remote.stories.tsx +314 -0
- package/src/elements/remote.tsx +221 -0
- package/src/elements/resizable.d.ts +27 -0
- package/src/elements/resizable.stories.tsx +426 -0
- package/src/elements/resizable.tsx +553 -0
- package/src/elements/response-stream.stories.tsx +79 -0
- package/src/elements/response-stream.tsx +40 -0
- package/src/elements/scroll-button.stories.tsx +202 -0
- package/src/elements/scroll-button.test.tsx +148 -0
- package/src/elements/scroll-button.tsx +126 -0
- package/src/elements/source-list.stories.tsx +143 -0
- package/src/elements/source.stories.tsx +88 -0
- package/src/elements/source.tsx +120 -0
- package/src/elements/styles.css +199 -0
- package/src/elements/switch.stories.tsx +56 -0
- package/src/elements/switch.tsx +43 -0
- package/src/elements/tasks.stories.tsx +237 -0
- package/src/elements/tasks.tsx +46 -0
- package/src/elements/text-shimmer.stories.tsx +64 -0
- package/src/elements/text-shimmer.tsx +28 -0
- package/src/elements/thinking-bar.stories.tsx +72 -0
- package/src/elements/thinking-bar.tsx +34 -0
- package/src/elements/tool.stories.tsx +89 -0
- package/src/elements/tool.tsx +23 -0
- package/src/elements/voice-input.stories.tsx +88 -0
- package/src/elements/voice-input.tsx +41 -0
- package/src/index.ts +253 -0
- package/src/primitives/card-contract.ts +71 -0
- package/src/primitives/card-host.tsx +35 -0
- package/src/primitives/card-registry.tsx +67 -0
- package/src/primitives/card-resolution.ts +36 -0
- package/src/primitives/card-routing.ts +79 -0
- package/src/primitives/card-schemas/card-envelope.schema.json +14 -0
- package/src/primitives/card-schemas/card-event.schema.json +12 -0
- package/src/primitives/card-schemas/choice.schema.json +70 -0
- package/src/primitives/card-schemas/confirm.schema.json +65 -0
- package/src/primitives/card-schemas/embed.schema.json +65 -0
- package/src/primitives/card-schemas/form.result.schema.json +7 -0
- package/src/primitives/card-schemas/form.schema.json +33 -0
- package/src/primitives/card-schemas/link.schema.json +56 -0
- package/src/primitives/card-schemas/tasks.result.schema.json +16 -0
- package/src/primitives/card-schemas/tasks.schema.json +78 -0
- package/src/primitives/card-validate.ts +95 -0
- package/src/primitives/chat-config.tsx +76 -0
- package/src/primitives/controllable.test.ts +47 -0
- package/src/primitives/controllable.ts +21 -0
- package/src/primitives/embed-providers.ts +254 -0
- package/src/primitives/highlighter.ts +157 -0
- package/src/primitives/link-preview.ts +87 -0
- package/src/primitives/pdf-preview.ts +121 -0
- package/src/primitives/use-auto-resize.ts +31 -0
- package/src/primitives/use-resize-observer.ts +12 -0
- package/src/primitives/use-stick-to-bottom.ts +43 -0
- package/src/primitives/use-text-stream.ts +112 -0
- package/src/primitives/use-voice-recorder.ts +50 -0
- package/src/remote/host-embed.ts +345 -0
- package/src/remote/index.ts +2 -0
- package/src/remote/origin.ts +30 -0
- package/src/remote/provider-runtime.ts +259 -0
- package/src/remote/provider.ts +2 -0
- package/src/remote/validate.ts +22 -0
- package/src/remote/version.ts +12 -0
- package/src/remote/wire.ts +48 -0
- package/src/stories/chat-panel-layout.stories.tsx +145 -0
- package/src/stories/chat-scene.tsx +571 -0
- package/src/stories/checkpoint-restore.stories.tsx +256 -0
- package/src/stories/context-usage.stories.tsx +212 -0
- package/src/stories/conversation-with-reasoning.stories.tsx +182 -0
- package/src/stories/conversation-with-sources.stories.tsx +471 -0
- package/src/stories/docs/Accessibility.mdx +119 -0
- package/src/stories/docs/ChildElements.mdx +269 -0
- package/src/stories/docs/ForAIAgents.mdx +162 -0
- package/src/stories/docs/GettingStarted.mdx +65 -0
- package/src/stories/docs/Installation.mdx +83 -0
- package/src/stories/docs/Introduction.mdx +50 -0
- package/src/stories/docs/SolidJsAdvanced.mdx +17 -0
- package/src/stories/docs/Theming.mdx +85 -0
- package/src/stories/docs/element-controls.ts +121 -0
- package/src/stories/docs/frameworks/Angular.mdx +245 -0
- package/src/stories/docs/frameworks/Html.mdx +227 -0
- package/src/stories/docs/frameworks/Overview.mdx +47 -0
- package/src/stories/docs/frameworks/React.mdx +227 -0
- package/src/stories/docs/frameworks/Solid.mdx +173 -0
- package/src/stories/docs/frameworks/Svelte.mdx +194 -0
- package/src/stories/docs/frameworks/Vue.mdx +238 -0
- package/src/stories/docs/generative-ui-overview.mdx +186 -0
- package/src/stories/docs/recipes/SpeechToText.mdx +7 -0
- package/src/stories/docs/recipes/Streaming.mdx +68 -0
- package/src/stories/docs/recipes/TextToSpeech.mdx +35 -0
- package/src/stories/docs/theme-editor/canvas.tsx +32 -0
- package/src/stories/docs/theme-editor/inspector.tsx +66 -0
- package/src/stories/docs/theme-editor/presets.test.ts +32 -0
- package/src/stories/docs/theme-editor/presets.ts +64 -0
- package/src/stories/docs/theme-editor/theme-css.test.ts +19 -0
- package/src/stories/docs/theme-editor/theme-css.ts +15 -0
- package/src/stories/docs/theme-editor/theme-editor.tsx +146 -0
- package/src/stories/docs/theme-tokens.tsx +174 -0
- package/src/stories/examples/ChoosingComponents.mdx +106 -0
- package/src/stories/examples/sample-data.ts +79 -0
- package/src/stories/examples/usage/checkpoint-restore.ts +156 -0
- package/src/stories/examples/usage/context-usage.ts +591 -0
- package/src/stories/examples/usage/conversation-with-reasoning.ts +224 -0
- package/src/stories/examples/usage/conversation-with-sources.ts +589 -0
- package/src/stories/examples/usage/empty-state.ts +144 -0
- package/src/stories/examples/usage/full-chat-app.ts +277 -0
- package/src/stories/examples/usage/index.ts +63 -0
- package/src/stories/examples/usage/message-actions.ts +704 -0
- package/src/stories/examples/usage/pattern-centered-conversation.ts +446 -0
- package/src/stories/examples/usage/pattern-chat-panel-layout.ts +442 -0
- package/src/stories/examples/usage/pattern-docked-widget.ts +479 -0
- package/src/stories/examples/usage/prompt-input-variants.ts +1356 -0
- package/src/stories/examples/usage/streaming-response.ts +687 -0
- package/src/stories/examples/usage/types.ts +33 -0
- package/src/stories/full-chat.stories.tsx +18 -0
- package/src/stories/message-actions.stories.tsx +230 -0
- package/src/stories/pattern-centered-conversation.stories.tsx +93 -0
- package/src/stories/pattern-docked-widget.stories.tsx +93 -0
- package/src/stories/pattern-empty-state.stories.tsx +76 -0
- package/src/stories/prompt-input-variants.stories.tsx +456 -0
- package/src/stories/streaming-response.stories.tsx +438 -0
- package/src/stories/theme-editor.stories.tsx +16 -0
- package/src/stories/token-reference.stories.tsx +18 -0
- package/src/stories/typography.stories.tsx +78 -0
- package/src/types.ts +47 -0
- package/src/ui/action-icons.ts +52 -0
- package/src/ui/avatar.stories.tsx +103 -0
- package/src/ui/avatar.tsx +23 -0
- package/src/ui/badge.stories.tsx +86 -0
- package/src/ui/badge.tsx +21 -0
- package/src/ui/button.stories.tsx +145 -0
- package/src/ui/button.tsx +38 -0
- package/src/ui/collapsible.stories.tsx +69 -0
- package/src/ui/collapsible.tsx +125 -0
- package/src/ui/dropdown.stories.tsx +59 -0
- package/src/ui/dropdown.tsx +196 -0
- package/src/ui/hover-card.stories.tsx +77 -0
- package/src/ui/hover-card.test.tsx +30 -0
- package/src/ui/hover-card.tsx +172 -0
- package/src/ui/overlay.stories.tsx +115 -0
- package/src/ui/overlay.tsx +158 -0
- package/src/ui/popover.stories.tsx +81 -0
- package/src/ui/popover.test.tsx +99 -0
- package/src/ui/popover.tsx +92 -0
- package/src/ui/resizable.stories.tsx +236 -0
- package/src/ui/resizable.tsx +576 -0
- package/src/ui/scroll-area.stories.tsx +50 -0
- package/src/ui/scroll-area.tsx +15 -0
- package/src/ui/separator.stories.tsx +81 -0
- package/src/ui/separator.tsx +10 -0
- package/src/ui/skeleton.stories.tsx +337 -0
- package/src/ui/skeleton.tsx +16 -0
- package/src/ui/switch.stories.tsx +51 -0
- package/src/ui/switch.test.tsx +59 -0
- package/src/ui/switch.tsx +62 -0
- package/src/ui/textarea.stories.tsx +76 -0
- package/src/ui/textarea.tsx +21 -0
- package/src/ui/tooltip.stories.tsx +74 -0
- package/src/ui/tooltip.tsx +68 -0
- package/src/utils/cn.ts +24 -0
- package/theme.css +187 -0
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { createSignal, createEffect, createMemo, Show, For, on, onCleanup } from "solid-js";
|
|
2
|
+
import { cn } from "../utils/cn";
|
|
3
|
+
import { usePromptInput } from "./prompt-input";
|
|
4
|
+
|
|
5
|
+
// --- Types ---
|
|
6
|
+
|
|
7
|
+
export interface SlashCommandItem {
|
|
8
|
+
id: string;
|
|
9
|
+
label: string;
|
|
10
|
+
description?: string;
|
|
11
|
+
category?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface SlashCommandProps {
|
|
15
|
+
commands: SlashCommandItem[];
|
|
16
|
+
activeIds?: string[]; // currently active command IDs — selecting again removes
|
|
17
|
+
onSelect: (command: SlashCommandItem) => void;
|
|
18
|
+
compact?: boolean; // single line: label + description side by side (default: true)
|
|
19
|
+
class?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// --- Component ---
|
|
23
|
+
|
|
24
|
+
function SlashCommand(props: SlashCommandProps) {
|
|
25
|
+
const ctx = usePromptInput();
|
|
26
|
+
const [open, setOpen] = createSignal(false);
|
|
27
|
+
const [selectedIndex, setSelectedIndex] = createSignal(0);
|
|
28
|
+
const [query, setQuery] = createSignal("");
|
|
29
|
+
const isCompact = props.compact !== false; // default true
|
|
30
|
+
let listRef: HTMLDivElement | undefined;
|
|
31
|
+
|
|
32
|
+
// Detect slash at the start of input
|
|
33
|
+
const slashMatch = createMemo(() => {
|
|
34
|
+
const val = ctx.value();
|
|
35
|
+
const match = val.match(/^\/(\S*)$/);
|
|
36
|
+
return match ? match[1] : null;
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// Filter and sort commands alphabetically
|
|
40
|
+
const filtered = createMemo(() => {
|
|
41
|
+
const q = slashMatch();
|
|
42
|
+
if (q === null) return [];
|
|
43
|
+
const items = q === ""
|
|
44
|
+
? [...props.commands]
|
|
45
|
+
: props.commands.filter(
|
|
46
|
+
(cmd) =>
|
|
47
|
+
cmd.label.toLowerCase().includes(q.toLowerCase()) ||
|
|
48
|
+
(cmd.description?.toLowerCase().includes(q.toLowerCase()) ?? false),
|
|
49
|
+
);
|
|
50
|
+
return items.sort((a, b) => a.label.localeCompare(b.label));
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// Group by category
|
|
54
|
+
const grouped = createMemo(() => {
|
|
55
|
+
const items = filtered();
|
|
56
|
+
const groups = new Map<string, SlashCommandItem[]>();
|
|
57
|
+
for (const item of items) {
|
|
58
|
+
const cat = item.category ?? "";
|
|
59
|
+
if (!groups.has(cat)) groups.set(cat, []);
|
|
60
|
+
groups.get(cat)!.push(item);
|
|
61
|
+
}
|
|
62
|
+
return groups;
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// Flat list for keyboard navigation
|
|
66
|
+
const flatList = createMemo(() => {
|
|
67
|
+
const items: SlashCommandItem[] = [];
|
|
68
|
+
for (const group of grouped().values()) {
|
|
69
|
+
items.push(...group);
|
|
70
|
+
}
|
|
71
|
+
return items;
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// Open/close based on slash detection
|
|
75
|
+
createEffect(
|
|
76
|
+
on(slashMatch, (match) => {
|
|
77
|
+
if (match !== null) {
|
|
78
|
+
setOpen(true);
|
|
79
|
+
setQuery(match);
|
|
80
|
+
setSelectedIndex(0);
|
|
81
|
+
} else {
|
|
82
|
+
setOpen(false);
|
|
83
|
+
}
|
|
84
|
+
}),
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
// Keep selected index in bounds
|
|
88
|
+
createEffect(() => {
|
|
89
|
+
const max = flatList().length;
|
|
90
|
+
if (selectedIndex() >= max) setSelectedIndex(Math.max(0, max - 1));
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// Scroll selected item into view
|
|
94
|
+
createEffect(() => {
|
|
95
|
+
const idx = selectedIndex();
|
|
96
|
+
if (!listRef) return;
|
|
97
|
+
const el = listRef.querySelector(`[data-index="${idx}"]`) as HTMLElement;
|
|
98
|
+
el?.scrollIntoView({ block: "nearest" });
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
function selectItem(item: SlashCommandItem) {
|
|
102
|
+
// Insert the chosen command into the prompt (e.g. "/summarize ") so it
|
|
103
|
+
// appears in the input ready to send or edit. The trailing space ends the
|
|
104
|
+
// slash token, which closes the palette. Still fire onSelect so consumers
|
|
105
|
+
// can react to the selection.
|
|
106
|
+
ctx.setValue(item.label + " ");
|
|
107
|
+
setOpen(false);
|
|
108
|
+
props.onSelect(item);
|
|
109
|
+
// Refocus the textarea and place the caret at the end.
|
|
110
|
+
setTimeout(() => {
|
|
111
|
+
const ta = ctx.textareaRef;
|
|
112
|
+
if (!ta) return;
|
|
113
|
+
ta.focus();
|
|
114
|
+
ta.setSelectionRange(ta.value.length, ta.value.length);
|
|
115
|
+
}, 0);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function handleKeyDown(e: KeyboardEvent) {
|
|
119
|
+
if (!open()) return;
|
|
120
|
+
|
|
121
|
+
const list = flatList();
|
|
122
|
+
if (list.length === 0) return;
|
|
123
|
+
|
|
124
|
+
switch (e.key) {
|
|
125
|
+
case "ArrowDown":
|
|
126
|
+
e.preventDefault();
|
|
127
|
+
setSelectedIndex((i) => (i + 1) % list.length);
|
|
128
|
+
break;
|
|
129
|
+
case "ArrowUp":
|
|
130
|
+
e.preventDefault();
|
|
131
|
+
setSelectedIndex((i) => (i - 1 + list.length) % list.length);
|
|
132
|
+
break;
|
|
133
|
+
case "Tab":
|
|
134
|
+
case "Enter":
|
|
135
|
+
e.preventDefault();
|
|
136
|
+
e.stopPropagation();
|
|
137
|
+
selectItem(list[selectedIndex()]);
|
|
138
|
+
break;
|
|
139
|
+
case "Escape":
|
|
140
|
+
e.preventDefault();
|
|
141
|
+
ctx.setValue("");
|
|
142
|
+
setOpen(false);
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Attach keyboard listener to textarea
|
|
148
|
+
createEffect(() => {
|
|
149
|
+
const textarea = ctx.textareaRef;
|
|
150
|
+
if (!textarea) return;
|
|
151
|
+
|
|
152
|
+
textarea.addEventListener("keydown", handleKeyDown, true);
|
|
153
|
+
onCleanup(() => textarea.removeEventListener("keydown", handleKeyDown, true));
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
let flatIndex = 0;
|
|
157
|
+
|
|
158
|
+
return (
|
|
159
|
+
<Show when={open() && flatList().length > 0}>
|
|
160
|
+
<div
|
|
161
|
+
class={cn(
|
|
162
|
+
"absolute bottom-full left-0 right-0 mb-1 z-50 bg-card rounded-lg shadow-lg overflow-hidden",
|
|
163
|
+
props.class,
|
|
164
|
+
)}
|
|
165
|
+
>
|
|
166
|
+
<div ref={listRef} class="max-h-56 overflow-y-auto py-1">
|
|
167
|
+
{(() => {
|
|
168
|
+
flatIndex = 0;
|
|
169
|
+
return null;
|
|
170
|
+
})()}
|
|
171
|
+
<For each={[...grouped().entries()]}>
|
|
172
|
+
{([category, items]) => (
|
|
173
|
+
<>
|
|
174
|
+
<Show when={category}>
|
|
175
|
+
<div class="px-3 pt-2 pb-1 text-[10px] font-semibold text-muted-foreground uppercase tracking-wide">
|
|
176
|
+
{category}
|
|
177
|
+
</div>
|
|
178
|
+
</Show>
|
|
179
|
+
<For each={items}>
|
|
180
|
+
{(item) => {
|
|
181
|
+
const idx = flatIndex++;
|
|
182
|
+
const isActive = () => props.activeIds?.includes(item.id) ?? false;
|
|
183
|
+
return (
|
|
184
|
+
<button
|
|
185
|
+
data-index={idx}
|
|
186
|
+
class={cn(
|
|
187
|
+
"w-full flex items-center gap-2 px-3 text-left transition-colors",
|
|
188
|
+
isCompact ? "py-1" : "py-1.5",
|
|
189
|
+
selectedIndex() === idx
|
|
190
|
+
? "bg-muted text-foreground"
|
|
191
|
+
: "text-foreground hover:bg-muted",
|
|
192
|
+
)}
|
|
193
|
+
onMouseEnter={() => setSelectedIndex(idx)}
|
|
194
|
+
onClick={() => selectItem(item)}
|
|
195
|
+
>
|
|
196
|
+
<Show when={isCompact} fallback={
|
|
197
|
+
<div class="flex-1 min-w-0">
|
|
198
|
+
<div class="text-xs flex items-center gap-1.5">
|
|
199
|
+
{item.label}
|
|
200
|
+
<Show when={isActive()}>
|
|
201
|
+
<span class="text-[10px] text-violet-600 dark:text-violet-400">active</span>
|
|
202
|
+
</Show>
|
|
203
|
+
</div>
|
|
204
|
+
<Show when={item.description}>
|
|
205
|
+
<div class="text-xs text-muted-foreground truncate">
|
|
206
|
+
{item.description}
|
|
207
|
+
</div>
|
|
208
|
+
</Show>
|
|
209
|
+
</div>
|
|
210
|
+
}>
|
|
211
|
+
<Show when={isActive()}>
|
|
212
|
+
<span class="w-1 h-1 rounded-full bg-violet-400 flex-shrink-0" />
|
|
213
|
+
</Show>
|
|
214
|
+
<span class={cn("text-xs flex-shrink-0", isActive() && "text-violet-600 dark:text-violet-400")}>{item.label}</span>
|
|
215
|
+
<Show when={item.description}>
|
|
216
|
+
<span class="text-xs text-muted-foreground truncate">{item.description}</span>
|
|
217
|
+
</Show>
|
|
218
|
+
</Show>
|
|
219
|
+
</button>
|
|
220
|
+
);
|
|
221
|
+
}}
|
|
222
|
+
</For>
|
|
223
|
+
</>
|
|
224
|
+
)}
|
|
225
|
+
</For>
|
|
226
|
+
</div>
|
|
227
|
+
</div>
|
|
228
|
+
</Show>
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export { SlashCommand };
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from 'vitest';
|
|
2
|
+
import '@testing-library/jest-dom/vitest';
|
|
3
|
+
import { render, cleanup } from '@solidjs/testing-library';
|
|
4
|
+
import { For } from 'solid-js';
|
|
5
|
+
import { Source, SourceTrigger, SourceContent, SourceList } from './source';
|
|
6
|
+
import { parseKaiSourceElement } from '../elements/source';
|
|
7
|
+
|
|
8
|
+
afterEach(cleanup);
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// parseKaiSourceElement — unit-tests for the attribute→SourceItem mapping
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
describe('parseKaiSourceElement', () => {
|
|
15
|
+
function makeEl(attrs: Record<string, string | null>): Element {
|
|
16
|
+
const el = document.createElement('kai-source');
|
|
17
|
+
for (const [k, v] of Object.entries(attrs)) {
|
|
18
|
+
if (v !== null) el.setAttribute(k, v);
|
|
19
|
+
}
|
|
20
|
+
return el;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
it('maps href, label, headline→title, description', () => {
|
|
24
|
+
const el = makeEl({
|
|
25
|
+
href: 'https://kitn.dev',
|
|
26
|
+
label: 'kitn',
|
|
27
|
+
headline: 'kitn — the kit',
|
|
28
|
+
description: 'Composable SolidJS chat UI.',
|
|
29
|
+
});
|
|
30
|
+
expect(parseKaiSourceElement(el)).toEqual({
|
|
31
|
+
href: 'https://kitn.dev',
|
|
32
|
+
label: 'kitn',
|
|
33
|
+
title: 'kitn — the kit',
|
|
34
|
+
description: 'Composable SolidJS chat UI.',
|
|
35
|
+
showFavicon: false,
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('sets showFavicon=true for a bare show-favicon attribute', () => {
|
|
40
|
+
const el = makeEl({ href: 'https://kitn.dev', 'show-favicon': '' });
|
|
41
|
+
expect(parseKaiSourceElement(el).showFavicon).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('sets showFavicon=false when show-favicon="false"', () => {
|
|
45
|
+
const el = makeEl({ href: 'https://kitn.dev', 'show-favicon': 'false' });
|
|
46
|
+
expect(parseKaiSourceElement(el).showFavicon).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('returns undefined for optional attrs when absent', () => {
|
|
50
|
+
const el = makeEl({ href: 'https://kitn.dev' });
|
|
51
|
+
const item = parseKaiSourceElement(el);
|
|
52
|
+
expect(item.label).toBeUndefined();
|
|
53
|
+
expect(item.title).toBeUndefined();
|
|
54
|
+
expect(item.description).toBeUndefined();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('falls back to empty string for missing href', () => {
|
|
58
|
+
const el = makeEl({});
|
|
59
|
+
expect(parseKaiSourceElement(el).href).toBe('');
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// SourceList component — verify citations render as anchor links
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
describe('SourceList with Source children', () => {
|
|
68
|
+
it('renders citation links for each source', () => {
|
|
69
|
+
const sources = [
|
|
70
|
+
{ href: 'https://kitn.dev', title: 'kitn', description: 'Composable chat UI.' },
|
|
71
|
+
{ href: 'https://solidjs.com', title: 'SolidJS', description: 'A reactive UI library.' },
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
const { container } = render(() => (
|
|
75
|
+
<SourceList>
|
|
76
|
+
{sources.map((s) => (
|
|
77
|
+
<Source href={s.href}>
|
|
78
|
+
<SourceTrigger />
|
|
79
|
+
<SourceContent title={s.title} description={s.description} />
|
|
80
|
+
</Source>
|
|
81
|
+
))}
|
|
82
|
+
</SourceList>
|
|
83
|
+
));
|
|
84
|
+
|
|
85
|
+
const links = container.querySelectorAll('a[href]');
|
|
86
|
+
// SourceTrigger and SourceContent each render an <a>; 2 sources × 2 = 4
|
|
87
|
+
expect(links.length).toBeGreaterThanOrEqual(2);
|
|
88
|
+
const hrefs = [...links].map((a) => a.getAttribute('href'));
|
|
89
|
+
expect(hrefs).toContain('https://kitn.dev');
|
|
90
|
+
expect(hrefs).toContain('https://solidjs.com');
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
// numbered prop — kai-sources web component behavior reproduced at Solid level
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
describe('numbered citations', () => {
|
|
99
|
+
/** Helper: render N sources with explicit numeric labels (mirroring numbered mode). */
|
|
100
|
+
function renderNumbered(sources: { href: string; label?: string | number }[]) {
|
|
101
|
+
return render(() => (
|
|
102
|
+
<SourceList>
|
|
103
|
+
<For each={sources}>
|
|
104
|
+
{(s, i) => (
|
|
105
|
+
<Source href={s.href}>
|
|
106
|
+
{/* When numbered is active the element replaces label with i()+1. */}
|
|
107
|
+
<SourceTrigger label={i() + 1} />
|
|
108
|
+
<SourceContent title="" description="" />
|
|
109
|
+
</Source>
|
|
110
|
+
)}
|
|
111
|
+
</For>
|
|
112
|
+
</SourceList>
|
|
113
|
+
));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
it('labels N sources 1..N in order', () => {
|
|
117
|
+
const sources = [
|
|
118
|
+
{ href: 'https://kitn.dev' },
|
|
119
|
+
{ href: 'https://solidjs.com' },
|
|
120
|
+
{ href: 'https://vitejs.dev' },
|
|
121
|
+
];
|
|
122
|
+
|
|
123
|
+
const { container } = renderNumbered(sources);
|
|
124
|
+
|
|
125
|
+
// SourceTrigger renders a <span> with the label inside the trigger <a>.
|
|
126
|
+
// Grab all trigger anchors — they have target="_blank".
|
|
127
|
+
const triggerLinks = [...container.querySelectorAll('a[target="_blank"]')];
|
|
128
|
+
// One trigger per source (SourceContent also renders an <a target="_blank">,
|
|
129
|
+
// so we narrow to ones whose text content is a number 1–N).
|
|
130
|
+
const labels = triggerLinks
|
|
131
|
+
.map((a) => a.querySelector('span')?.textContent?.trim())
|
|
132
|
+
.filter(Boolean);
|
|
133
|
+
|
|
134
|
+
expect(labels).toEqual(['1', '2', '3']);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('label sequence resets from 1 for each independent list', () => {
|
|
138
|
+
const firstSources = [{ href: 'https://kitn.dev' }, { href: 'https://solidjs.com' }];
|
|
139
|
+
const secondSources = [{ href: 'https://vitejs.dev' }, { href: 'https://vitest.dev' }];
|
|
140
|
+
|
|
141
|
+
const { container: c1 } = renderNumbered(firstSources);
|
|
142
|
+
const { container: c2 } = renderNumbered(secondSources);
|
|
143
|
+
|
|
144
|
+
const getLabels = (c: HTMLElement) =>
|
|
145
|
+
[...c.querySelectorAll('a[target="_blank"]')]
|
|
146
|
+
.map((a) => a.querySelector('span')?.textContent?.trim())
|
|
147
|
+
.filter(Boolean);
|
|
148
|
+
|
|
149
|
+
expect(getLabels(c1)).toEqual(['1', '2']);
|
|
150
|
+
expect(getLabels(c2)).toEqual(['1', '2']);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from 'storybook-solidjs-vite';
|
|
2
|
+
import { Source, SourceTrigger, SourceContent, SourceList } from './source';
|
|
3
|
+
import { componentDescription } from '../stories/docs/element-controls';
|
|
4
|
+
|
|
5
|
+
const meta = {
|
|
6
|
+
title: 'Solid (Advanced)/Elements/Source',
|
|
7
|
+
component: Source,
|
|
8
|
+
tags: ['autodocs'],
|
|
9
|
+
parameters: {
|
|
10
|
+
layout: 'padded',
|
|
11
|
+
docs: {
|
|
12
|
+
description: componentDescription([
|
|
13
|
+
'An inline citation chip with a hover-card preview. Compose `Source` (root, holds the `href`) with `SourceTrigger` (the clickable pill) and `SourceContent` (the hover preview). `SourceList` lays out several side by side.',
|
|
14
|
+
'**When to use:** to cite a referenced web source inline in an assistant message — show a compact domain/number pill that previews the title and description on hover.',
|
|
15
|
+
'**How to use:** wrap `SourceTrigger` and `SourceContent` in `Source` with the target `href`. Use `label` for custom text or a citation number, `showFavicon` for the site icon, and pass `title`/`description` to `SourceContent`.',
|
|
16
|
+
'**Placement:** within message body text as citations, or grouped under a message in a `SourceList`.',
|
|
17
|
+
]),
|
|
18
|
+
controls: { exclude: ['use:eventListener'] },
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
argTypes: {
|
|
22
|
+
href: {
|
|
23
|
+
control: 'text',
|
|
24
|
+
description: 'Target URL of the source. The domain is derived from it for the default label.',
|
|
25
|
+
},
|
|
26
|
+
children: { control: false, description: 'Trigger and content composition.' },
|
|
27
|
+
},
|
|
28
|
+
args: {
|
|
29
|
+
href: 'https://solidjs.com/docs/basic-reactivity/signals',
|
|
30
|
+
},
|
|
31
|
+
render: (args) => (
|
|
32
|
+
<Source href={args.href}>
|
|
33
|
+
<SourceTrigger label="solidjs.com" />
|
|
34
|
+
<SourceContent
|
|
35
|
+
title="Signals - SolidJS"
|
|
36
|
+
description="Signals are the most basic reactive primitive. They track a single value that changes over time."
|
|
37
|
+
/>
|
|
38
|
+
</Source>
|
|
39
|
+
),
|
|
40
|
+
} satisfies Meta<typeof Source>;
|
|
41
|
+
|
|
42
|
+
export default meta;
|
|
43
|
+
type Story = StoryObj<typeof meta>;
|
|
44
|
+
|
|
45
|
+
const IMPORT = `import { Source, SourceTrigger, SourceContent, SourceList } from '@kitn.ai/ui';`;
|
|
46
|
+
const src = (code: string) => ({
|
|
47
|
+
parameters: { docs: { source: { code: `${IMPORT}\n\n${code}`, language: 'tsx' } } },
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
/** Interactive playground — set the `href` and hover the chip to preview. */
|
|
51
|
+
export const Playground: Story = {
|
|
52
|
+
...src(`<Source href="https://solidjs.com/docs/basic-reactivity/signals">
|
|
53
|
+
<SourceTrigger label="solidjs.com" />
|
|
54
|
+
<SourceContent
|
|
55
|
+
title="Signals - SolidJS"
|
|
56
|
+
description="The most basic reactive primitive — a single value that changes over time."
|
|
57
|
+
/>
|
|
58
|
+
</Source>`),
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** Trigger shows the site favicon and derives its label from the domain. */
|
|
62
|
+
export const WithFavicon: Story = {
|
|
63
|
+
render: () => (
|
|
64
|
+
<Source href="https://developer.mozilla.org/en-US/docs/Web/JavaScript">
|
|
65
|
+
<SourceTrigger showFavicon />
|
|
66
|
+
<SourceContent
|
|
67
|
+
title="JavaScript | MDN"
|
|
68
|
+
description="JavaScript (JS) is a lightweight interpreted programming language with first-class functions."
|
|
69
|
+
/>
|
|
70
|
+
</Source>
|
|
71
|
+
),
|
|
72
|
+
...src(`<Source href="https://developer.mozilla.org/en-US/docs/Web/JavaScript">
|
|
73
|
+
<SourceTrigger showFavicon />
|
|
74
|
+
<SourceContent title="JavaScript | MDN" description="A lightweight interpreted language." />
|
|
75
|
+
</Source>`),
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/** A numbered citation chip. */
|
|
79
|
+
export const NumberedCitation: Story = {
|
|
80
|
+
render: () => (
|
|
81
|
+
<Source href="https://example.com/article">
|
|
82
|
+
<SourceTrigger label={1} />
|
|
83
|
+
<SourceContent
|
|
84
|
+
title="Example Article"
|
|
85
|
+
description="This is a sample article used as a citation reference."
|
|
86
|
+
/>
|
|
87
|
+
</Source>
|
|
88
|
+
),
|
|
89
|
+
...src(`<Source href="https://example.com/article">
|
|
90
|
+
<SourceTrigger label={1} />
|
|
91
|
+
<SourceContent title="Example Article" description="A sample citation reference." />
|
|
92
|
+
</Source>`),
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/** Several sources laid out together with `SourceList` (showcase). */
|
|
96
|
+
export const SourceListExample: Story = {
|
|
97
|
+
name: 'Source list',
|
|
98
|
+
render: () => (
|
|
99
|
+
<SourceList>
|
|
100
|
+
<Source href="https://solidjs.com">
|
|
101
|
+
<SourceTrigger showFavicon />
|
|
102
|
+
<SourceContent title="SolidJS" description="Simple and performant reactivity for building user interfaces." />
|
|
103
|
+
</Source>
|
|
104
|
+
<Source href="https://developer.mozilla.org">
|
|
105
|
+
<SourceTrigger showFavicon />
|
|
106
|
+
<SourceContent title="MDN Web Docs" description="Resources for developers, by developers." />
|
|
107
|
+
</Source>
|
|
108
|
+
<Source href="https://tailwindcss.com">
|
|
109
|
+
<SourceTrigger showFavicon />
|
|
110
|
+
<SourceContent title="Tailwind CSS" description="A utility-first CSS framework." />
|
|
111
|
+
</Source>
|
|
112
|
+
</SourceList>
|
|
113
|
+
),
|
|
114
|
+
...src(`<SourceList>
|
|
115
|
+
<Source href="https://solidjs.com">
|
|
116
|
+
<SourceTrigger showFavicon />
|
|
117
|
+
<SourceContent title="SolidJS" description="Simple and performant reactivity." />
|
|
118
|
+
</Source>
|
|
119
|
+
<Source href="https://tailwindcss.com">
|
|
120
|
+
<SourceTrigger showFavicon />
|
|
121
|
+
<SourceContent title="Tailwind CSS" description="A utility-first CSS framework." />
|
|
122
|
+
</Source>
|
|
123
|
+
</SourceList>`),
|
|
124
|
+
};
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { type JSX, createContext, useContext, Show, splitProps } from 'solid-js';
|
|
2
|
+
import { cn } from '../utils/cn';
|
|
3
|
+
import { HoverCardRoot, HoverCardTrigger, HoverCardContent } from '../ui/hover-card';
|
|
4
|
+
|
|
5
|
+
// --- Context ---
|
|
6
|
+
|
|
7
|
+
interface SourceContextValue {
|
|
8
|
+
href: string;
|
|
9
|
+
domain: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const SourceContext = createContext<SourceContextValue>();
|
|
13
|
+
|
|
14
|
+
function useSourceContext() {
|
|
15
|
+
const ctx = useContext(SourceContext);
|
|
16
|
+
if (!ctx) throw new Error('Source.* must be used inside <Source>');
|
|
17
|
+
return ctx;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// --- Source (Root) ---
|
|
21
|
+
|
|
22
|
+
export interface SourceProps {
|
|
23
|
+
href: string;
|
|
24
|
+
children: JSX.Element;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function Source(props: SourceProps) {
|
|
28
|
+
const domain = () => {
|
|
29
|
+
try {
|
|
30
|
+
return new URL(props.href).hostname;
|
|
31
|
+
} catch {
|
|
32
|
+
return props.href.split('/').pop() || props.href;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<SourceContext.Provider value={{ get href() { return props.href; }, get domain() { return domain(); } }}>
|
|
38
|
+
<HoverCardRoot openDelay={150}>
|
|
39
|
+
{props.children}
|
|
40
|
+
</HoverCardRoot>
|
|
41
|
+
</SourceContext.Provider>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// --- SourceTrigger ---
|
|
46
|
+
|
|
47
|
+
export interface SourceTriggerProps {
|
|
48
|
+
label?: string | number;
|
|
49
|
+
showFavicon?: boolean;
|
|
50
|
+
class?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function SourceTrigger(props: SourceTriggerProps) {
|
|
54
|
+
const ctx = useSourceContext();
|
|
55
|
+
const labelToShow = () => props.label ?? ctx.domain.replace('www.', '');
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<HoverCardTrigger>
|
|
59
|
+
<a
|
|
60
|
+
href={ctx.href}
|
|
61
|
+
target="_blank"
|
|
62
|
+
rel="noopener noreferrer"
|
|
63
|
+
class={cn(
|
|
64
|
+
'bg-muted text-foreground/80 hover:bg-muted-foreground/30 hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background inline-flex h-5 max-w-32 items-center gap-1 overflow-hidden rounded-full py-0 text-xs no-underline transition-colors duration-150',
|
|
65
|
+
props.showFavicon ? 'pr-2 pl-1' : 'px-2',
|
|
66
|
+
props.class
|
|
67
|
+
)}
|
|
68
|
+
>
|
|
69
|
+
<Show when={props.showFavicon}>
|
|
70
|
+
<img
|
|
71
|
+
src={`https://www.google.com/s2/favicons?sz=64&domain_url=${encodeURIComponent(ctx.href)}`}
|
|
72
|
+
alt="favicon"
|
|
73
|
+
width={14}
|
|
74
|
+
height={14}
|
|
75
|
+
class="size-3.5 rounded-full"
|
|
76
|
+
/>
|
|
77
|
+
</Show>
|
|
78
|
+
<span class="truncate tabular-nums text-center font-normal">{labelToShow()}</span>
|
|
79
|
+
</a>
|
|
80
|
+
</HoverCardTrigger>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// --- SourceContent ---
|
|
85
|
+
|
|
86
|
+
export interface SourceContentProps {
|
|
87
|
+
title: string;
|
|
88
|
+
description: string;
|
|
89
|
+
class?: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function SourceContent(props: SourceContentProps) {
|
|
93
|
+
const ctx = useSourceContext();
|
|
94
|
+
return (
|
|
95
|
+
<HoverCardContent class={cn('w-80 p-0 shadow-xs', props.class)}>
|
|
96
|
+
<a
|
|
97
|
+
href={ctx.href}
|
|
98
|
+
target="_blank"
|
|
99
|
+
rel="noopener noreferrer"
|
|
100
|
+
class="flex flex-col gap-2 p-3"
|
|
101
|
+
>
|
|
102
|
+
<div class="flex items-center gap-1.5">
|
|
103
|
+
<img
|
|
104
|
+
src={`https://www.google.com/s2/favicons?sz=64&domain_url=${encodeURIComponent(ctx.href)}`}
|
|
105
|
+
alt="favicon"
|
|
106
|
+
class="size-4 rounded-full"
|
|
107
|
+
width={16}
|
|
108
|
+
height={16}
|
|
109
|
+
/>
|
|
110
|
+
<div class="text-primary truncate text-sm">
|
|
111
|
+
{ctx.domain.replace('www.', '')}
|
|
112
|
+
</div>
|
|
113
|
+
</div>
|
|
114
|
+
<div class="line-clamp-2 text-sm font-medium">{props.title}</div>
|
|
115
|
+
<div class="text-muted-foreground line-clamp-2 text-sm">
|
|
116
|
+
{props.description}
|
|
117
|
+
</div>
|
|
118
|
+
</a>
|
|
119
|
+
</HoverCardContent>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// --- SourceList (convenience) ---
|
|
124
|
+
|
|
125
|
+
function SourceList(props: { children: JSX.Element; class?: string }) {
|
|
126
|
+
return <div class={cn('flex flex-wrap gap-1.5 mt-3', props.class)}>{props.children}</div>;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export { Source, SourceTrigger, SourceContent, SourceList };
|