@kitnai/chat 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -0
- package/dist/custom-elements.json +2494 -0
- package/dist/kitn-chat.es.js +52 -39
- package/dist/llms/llms-full.txt +667 -0
- package/dist/llms/llms.txt +104 -0
- package/dist/theme.tokens.css +133 -0
- package/frameworks/react/index.tsx +530 -0
- package/frameworks/react/runtime.tsx +94 -0
- package/llms-full.txt +667 -0
- package/llms.txt +104 -0
- package/package.json +34 -5
- package/src/components/attachments.tsx +4 -2
- package/src/components/chain-of-thought.tsx +1 -1
- package/src/components/chat-scope-picker.tsx +2 -2
- package/src/components/checkpoint.tsx +7 -3
- package/src/components/context.tsx +14 -18
- package/src/components/conversation-item.tsx +1 -1
- package/src/components/conversation-list.tsx +5 -4
- package/src/components/message-skills.tsx +1 -1
- package/src/components/message.tsx +1 -0
- package/src/components/model-switcher.tsx +3 -3
- package/src/components/prompt-input.tsx +15 -2
- package/src/components/reasoning.tsx +2 -2
- package/src/components/scroll-button.tsx +1 -0
- package/src/components/slash-command.tsx +17 -8
- package/src/components/source.tsx +2 -2
- package/src/components/thinking-bar.tsx +2 -2
- package/src/components/tool.tsx +17 -6
- package/src/components/voice-input.tsx +5 -1
- package/src/elements/attachments.tsx +132 -0
- package/src/elements/chain-of-thought.tsx +45 -0
- package/src/elements/chat-scope-picker.tsx +36 -0
- package/src/elements/chat.tsx +51 -7
- package/src/elements/checkpoint.tsx +43 -0
- package/src/elements/code-block.tsx +42 -0
- package/src/elements/compiled.css +1 -1
- package/src/elements/context-meter.tsx +71 -0
- package/src/elements/conversation-list.tsx +6 -0
- package/src/elements/default-input.tsx +22 -1
- package/src/elements/define.tsx +102 -13
- package/src/elements/element-types.d.ts +404 -0
- package/src/elements/empty.tsx +29 -0
- package/src/elements/feedback-bar.tsx +33 -0
- package/src/elements/file-upload.tsx +44 -0
- package/src/elements/image.tsx +32 -0
- package/src/elements/kitn-attachments.stories.tsx +181 -0
- package/src/elements/kitn-chain-of-thought.stories.tsx +75 -0
- package/src/elements/kitn-chat-scope-picker.stories.tsx +72 -0
- package/src/elements/kitn-checkpoint.stories.tsx +71 -0
- package/src/elements/kitn-code-block.stories.tsx +82 -0
- package/src/elements/kitn-context-meter.stories.tsx +85 -0
- package/src/elements/kitn-empty.stories.tsx +110 -0
- package/src/elements/kitn-feedback-bar.stories.tsx +73 -0
- package/src/elements/kitn-file-upload.stories.tsx +81 -0
- package/src/elements/kitn-image.stories.tsx +70 -0
- package/src/elements/kitn-loader.stories.tsx +87 -0
- package/src/elements/kitn-markdown.stories.tsx +75 -0
- package/src/elements/kitn-message-skills.stories.tsx +74 -0
- package/src/elements/kitn-message.stories.tsx +105 -0
- package/src/elements/kitn-model-switcher.stories.tsx +80 -0
- package/src/elements/kitn-prompt-input.stories.tsx +74 -16
- package/src/elements/kitn-prompt-suggestions.stories.tsx +157 -0
- package/src/elements/kitn-reasoning.stories.tsx +76 -0
- package/src/elements/kitn-response-stream.stories.tsx +79 -0
- package/src/elements/kitn-source-list.stories.tsx +77 -0
- package/src/elements/kitn-source.stories.tsx +87 -0
- package/src/elements/kitn-text-shimmer.stories.tsx +63 -0
- package/src/elements/kitn-thinking-bar.stories.tsx +72 -0
- package/src/elements/kitn-tool.stories.tsx +88 -0
- package/src/elements/kitn-voice-input.stories.tsx +87 -0
- package/src/elements/loader.tsx +25 -0
- package/src/elements/markdown.tsx +38 -0
- package/src/elements/message-skills.tsx +22 -0
- package/src/elements/message.tsx +125 -0
- package/src/elements/model-switcher.tsx +35 -0
- package/src/elements/prompt-input.tsx +83 -7
- package/src/elements/prompt-suggestions.tsx +58 -0
- package/src/elements/reasoning.tsx +50 -0
- package/src/elements/register.ts +31 -0
- package/src/elements/response-stream.tsx +40 -0
- package/src/elements/source.tsx +67 -0
- package/src/elements/text-shimmer.tsx +28 -0
- package/src/elements/thinking-bar.tsx +34 -0
- package/src/elements/tool.tsx +23 -0
- package/src/elements/voice-input.tsx +41 -0
- package/src/index.ts +0 -1
- package/src/primitives/chat-config.tsx +2 -2
- package/src/stories/docs/Accessibility.mdx +119 -0
- package/src/stories/docs/ForAIAgents.mdx +93 -0
- package/src/stories/docs/GettingStarted.mdx +2 -2
- package/src/stories/docs/Installation.mdx +2 -2
- package/src/stories/docs/Integrations.mdx +415 -15
- package/src/stories/docs/Introduction.mdx +5 -5
- package/src/stories/docs/Theming.mdx +1 -1
- package/src/stories/typography.stories.tsx +78 -0
- package/src/ui/button.tsx +1 -1
- package/src/ui/collapsible.tsx +119 -8
- package/src/ui/dropdown.tsx +177 -12
- package/src/ui/hover-card.tsx +147 -26
- package/src/ui/overlay.tsx +151 -0
- package/src/ui/textarea.tsx +1 -1
- package/src/ui/tooltip.stories.tsx +1 -1
- package/src/ui/tooltip.tsx +59 -13
- package/src/utils/cn.ts +19 -1
- package/theme.css +72 -43
- package/src/ui/dialog.tsx +0 -21
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from 'storybook-solidjs-vite';
|
|
2
|
+
import { For, createSignal, onMount } from 'solid-js';
|
|
3
|
+
|
|
4
|
+
const meta = {
|
|
5
|
+
title: 'Theming/Typography',
|
|
6
|
+
parameters: { layout: 'padded' },
|
|
7
|
+
} satisfies Meta;
|
|
8
|
+
|
|
9
|
+
export default meta;
|
|
10
|
+
type Story = StoryObj;
|
|
11
|
+
|
|
12
|
+
// The semantic type scale (defined in theme.css `@theme`). Each row is a token
|
|
13
|
+
// that generates a Tailwind utility AND is an overridable CSS custom property.
|
|
14
|
+
const SCALE = [
|
|
15
|
+
{ token: '--text-caption', cls: 'text-caption', role: 'Micro labels, badges, sub-counts', used: 'token sub-totals · model provider · media-type subtitle' },
|
|
16
|
+
{ token: '--text-meta', cls: 'text-meta', role: 'Controls, toggles, switchers, captions', used: 'reasoning / chain-of-thought triggers · model switcher · context · source' },
|
|
17
|
+
{ token: '--text-body', cls: 'text-body', role: 'Primary reading text', used: 'messages · input · suggestions · markdown (also scales with the `proseSize` prop)' },
|
|
18
|
+
{ token: '--text-title', cls: 'text-title', role: 'Emphasis / headers', used: 'section emphasis' },
|
|
19
|
+
] as const;
|
|
20
|
+
|
|
21
|
+
function TypographyScale() {
|
|
22
|
+
const [px, setPx] = createSignal<Record<string, string>>({});
|
|
23
|
+
onMount(() => {
|
|
24
|
+
const cs = getComputedStyle(document.documentElement);
|
|
25
|
+
const toPx = (rem: string) => {
|
|
26
|
+
const v = parseFloat(rem);
|
|
27
|
+
return rem.includes('rem') ? `${Math.round(v * 16)}px` : rem.trim();
|
|
28
|
+
};
|
|
29
|
+
setPx(Object.fromEntries(SCALE.map((s) => [s.token, toPx(cs.getPropertyValue(s.token))])));
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<div class="text-foreground max-w-3xl">
|
|
34
|
+
<h2 class="mb-1 text-lg font-semibold">Typography scale</h2>
|
|
35
|
+
<p class="text-muted-foreground mb-6 text-sm">
|
|
36
|
+
Defined once in <code class="text-code-foreground">theme.css</code>. Each token generates a Tailwind utility
|
|
37
|
+
(<code class="text-code-foreground">text-meta</code>, …). To restyle the kit's typography globally, override the
|
|
38
|
+
namespaced <code class="text-code-foreground">--kitn-text-*</code> token on <code class="text-code-foreground">:root</code> —
|
|
39
|
+
it pierces the Shadow DOM exactly like the <code class="text-code-foreground">--kitn-color-*</code> tokens. (The bare
|
|
40
|
+
<code class="text-code-foreground"> --text-*</code> names stay internal, so a host's own tokens can't collide.)
|
|
41
|
+
</p>
|
|
42
|
+
|
|
43
|
+
<div class="border-border divide-border divide-y rounded-xl border">
|
|
44
|
+
<For each={SCALE}>
|
|
45
|
+
{(s) => (
|
|
46
|
+
<div class="grid grid-cols-[170px_1fr] items-center gap-4 p-4">
|
|
47
|
+
<div class="min-w-0">
|
|
48
|
+
<div class="text-foreground font-mono text-xs">{s.token}</div>
|
|
49
|
+
<div class="text-muted-foreground mt-1 font-mono text-[11px]">{px()[s.token] ?? '…'}</div>
|
|
50
|
+
<div class="text-muted-foreground mt-2 text-xs">{s.role}</div>
|
|
51
|
+
</div>
|
|
52
|
+
<div class="min-w-0">
|
|
53
|
+
<div class={`${s.cls} text-foreground`}>The quick brown fox jumps over the lazy dog</div>
|
|
54
|
+
<div class="text-muted-foreground mt-1.5 text-[11px]">{s.used}</div>
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
)}
|
|
58
|
+
</For>
|
|
59
|
+
</div>
|
|
60
|
+
|
|
61
|
+
<h3 class="mt-8 mb-2 text-sm font-semibold">Override example</h3>
|
|
62
|
+
<pre class="bg-muted text-foreground overflow-auto rounded-lg p-3 font-mono text-xs">{`:root {
|
|
63
|
+
--kitn-text-body: 0.9375rem; /* bump the reading size to 15px */
|
|
64
|
+
--kitn-text-meta: 0.8125rem; /* and the control size to 13px */
|
|
65
|
+
}`}</pre>
|
|
66
|
+
<p class="text-muted-foreground mt-2 text-xs">
|
|
67
|
+
Reading text in messages / input / markdown additionally scales with the
|
|
68
|
+
<code class="text-code-foreground"> proseSize</code> prop (<code class="text-code-foreground">xs · sm · base · lg</code>);
|
|
69
|
+
these tokens cover the fixed chrome & controls.
|
|
70
|
+
</p>
|
|
71
|
+
</div>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The kit's semantic type scale — defined in theme.css, used everywhere, overridable. */
|
|
76
|
+
export const Typography: Story = {
|
|
77
|
+
render: () => <TypographyScale />,
|
|
78
|
+
};
|
package/src/ui/button.tsx
CHANGED
|
@@ -3,7 +3,7 @@ import { cva, type VariantProps } from 'class-variance-authority';
|
|
|
3
3
|
import { cn } from '../utils/cn';
|
|
4
4
|
|
|
5
5
|
const buttonVariants = cva(
|
|
6
|
-
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-
|
|
6
|
+
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50',
|
|
7
7
|
{
|
|
8
8
|
variants: {
|
|
9
9
|
variant: {
|
package/src/ui/collapsible.tsx
CHANGED
|
@@ -1,14 +1,125 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
useContext,
|
|
4
|
+
createSignal,
|
|
5
|
+
createUniqueId,
|
|
6
|
+
splitProps,
|
|
7
|
+
type JSX,
|
|
8
|
+
type Accessor,
|
|
9
|
+
} from 'solid-js';
|
|
3
10
|
import { cn } from '../utils/cn';
|
|
4
11
|
|
|
5
|
-
|
|
6
|
-
|
|
12
|
+
// Extend SolidJS JSX to allow `bool:inert` (forces setAttribute so jsdom reflects it as an attribute).
|
|
13
|
+
declare module 'solid-js' {
|
|
14
|
+
namespace JSX {
|
|
15
|
+
interface ExplicitBoolAttributes {
|
|
16
|
+
inert: boolean;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface CollapsibleCtx {
|
|
22
|
+
open: Accessor<boolean>;
|
|
23
|
+
toggle: () => void;
|
|
24
|
+
contentId: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const Ctx = createContext<CollapsibleCtx>();
|
|
28
|
+
|
|
29
|
+
const useCollapsible = () => {
|
|
30
|
+
const c = useContext(Ctx);
|
|
31
|
+
if (!c) throw new Error('Collapsible parts must be used within <Collapsible>');
|
|
32
|
+
return c;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export function Collapsible(props: {
|
|
36
|
+
open?: boolean;
|
|
37
|
+
defaultOpen?: boolean;
|
|
38
|
+
onOpenChange?: (open: boolean) => void;
|
|
39
|
+
children: JSX.Element;
|
|
40
|
+
class?: string;
|
|
41
|
+
}) {
|
|
42
|
+
const [local, rest] = splitProps(props, ['open', 'defaultOpen', 'onOpenChange', 'children', 'class']);
|
|
43
|
+
const [uncontrolled, setUncontrolled] = createSignal(local.defaultOpen ?? false);
|
|
44
|
+
const isControlled = () => local.open !== undefined;
|
|
45
|
+
const open = () => (isControlled() ? !!local.open : uncontrolled());
|
|
46
|
+
const toggle = () => {
|
|
47
|
+
const next = !open();
|
|
48
|
+
if (!isControlled()) setUncontrolled(next);
|
|
49
|
+
local.onOpenChange?.(next);
|
|
50
|
+
};
|
|
51
|
+
const contentId = createUniqueId();
|
|
52
|
+
return (
|
|
53
|
+
<Ctx.Provider value={{ open, toggle, contentId }}>
|
|
54
|
+
<div
|
|
55
|
+
class={local.class}
|
|
56
|
+
{...rest}
|
|
57
|
+
data-expanded={open() ? '' : undefined}
|
|
58
|
+
data-closed={open() ? undefined : ''}
|
|
59
|
+
data-state={open() ? 'open' : 'closed'}
|
|
60
|
+
>
|
|
61
|
+
{local.children}
|
|
62
|
+
</div>
|
|
63
|
+
</Ctx.Provider>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function CollapsibleTrigger(props: {
|
|
68
|
+
children?: JSX.Element;
|
|
69
|
+
class?: string;
|
|
70
|
+
as?: (props: Record<string, any>) => JSX.Element;
|
|
71
|
+
onClick?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>;
|
|
72
|
+
[k: string]: any;
|
|
73
|
+
}) {
|
|
74
|
+
const ctx = useCollapsible();
|
|
75
|
+
const [local, rest] = splitProps(props, ['children', 'class', 'as', 'onClick']);
|
|
76
|
+
|
|
77
|
+
const triggerProps = () => ({
|
|
78
|
+
type: 'button' as const,
|
|
79
|
+
'aria-expanded': ctx.open(),
|
|
80
|
+
'aria-controls': ctx.contentId,
|
|
81
|
+
'data-expanded': ctx.open() ? '' : undefined,
|
|
82
|
+
'data-closed': ctx.open() ? undefined : '',
|
|
83
|
+
'data-state': ctx.open() ? 'open' : 'closed',
|
|
84
|
+
onClick: (e: MouseEvent) => {
|
|
85
|
+
if (typeof local.onClick === 'function') {
|
|
86
|
+
(local.onClick as (e: MouseEvent) => void)(e);
|
|
87
|
+
}
|
|
88
|
+
ctx.toggle();
|
|
89
|
+
},
|
|
90
|
+
class: local.class,
|
|
91
|
+
...rest,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<>
|
|
96
|
+
{local.as
|
|
97
|
+
? local.as(triggerProps() as any)
|
|
98
|
+
: (
|
|
99
|
+
<button {...triggerProps()}>
|
|
100
|
+
{local.children}
|
|
101
|
+
</button>
|
|
102
|
+
)}
|
|
103
|
+
</>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
7
106
|
|
|
8
|
-
export function CollapsibleContent(props: { children
|
|
107
|
+
export function CollapsibleContent(props: { children?: JSX.Element; class?: string; [k: string]: any }) {
|
|
108
|
+
const ctx = useCollapsible();
|
|
109
|
+
const [local, rest] = splitProps(props, ['children', 'class']);
|
|
9
110
|
return (
|
|
10
|
-
<
|
|
11
|
-
{
|
|
12
|
-
|
|
111
|
+
<div
|
|
112
|
+
{...rest}
|
|
113
|
+
id={ctx.contentId}
|
|
114
|
+
data-expanded={ctx.open() ? '' : undefined}
|
|
115
|
+
data-closed={ctx.open() ? undefined : ''}
|
|
116
|
+
class={cn(
|
|
117
|
+
'grid transition-[grid-template-rows] duration-200 ease-out',
|
|
118
|
+
ctx.open() ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]',
|
|
119
|
+
)}
|
|
120
|
+
bool:inert={!ctx.open()}
|
|
121
|
+
>
|
|
122
|
+
<div class={cn('overflow-hidden', local.class)}>{local.children}</div>
|
|
123
|
+
</div>
|
|
13
124
|
);
|
|
14
125
|
}
|
package/src/ui/dropdown.tsx
CHANGED
|
@@ -1,26 +1,191 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
createContext, useContext, createSignal, createUniqueId, Show,
|
|
3
|
+
type JSX, type Accessor,
|
|
4
|
+
} from 'solid-js';
|
|
5
|
+
import { Portal } from 'solid-js/web';
|
|
3
6
|
import { cn } from '../utils/cn';
|
|
4
7
|
import { useChatConfig } from '../primitives/chat-config';
|
|
8
|
+
import { createPresence, usePosition, useDismiss, As, type AsTag } from './overlay';
|
|
5
9
|
|
|
6
|
-
|
|
7
|
-
|
|
10
|
+
interface DropdownCtx {
|
|
11
|
+
open: Accessor<boolean>;
|
|
12
|
+
setOpen: (v: boolean, opts?: { viaKeyboard?: boolean; returnFocus?: boolean }) => void;
|
|
13
|
+
triggerId: string;
|
|
14
|
+
menuId: string;
|
|
15
|
+
setTrigger: (el: HTMLElement) => void;
|
|
16
|
+
setMenu: (el: HTMLElement) => void;
|
|
17
|
+
trigger: Accessor<HTMLElement | undefined>;
|
|
18
|
+
menu: Accessor<HTMLElement | undefined>;
|
|
19
|
+
openedViaKeyboard: Accessor<boolean>;
|
|
20
|
+
}
|
|
21
|
+
const Ctx = createContext<DropdownCtx>();
|
|
22
|
+
const useDropdown = () => {
|
|
23
|
+
const c = useContext(Ctx);
|
|
24
|
+
if (!c) throw new Error('Dropdown parts must be used within <Dropdown>');
|
|
25
|
+
return c;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function Dropdown(props: { children: JSX.Element }) {
|
|
29
|
+
const [open, setOpenSig] = createSignal(false);
|
|
30
|
+
const [viaKb, setViaKb] = createSignal(false);
|
|
31
|
+
const [trigger, setTrigger] = createSignal<HTMLElement>();
|
|
32
|
+
const [menu, setMenu] = createSignal<HTMLElement>();
|
|
33
|
+
const setOpen = (v: boolean, opts?: { viaKeyboard?: boolean; returnFocus?: boolean }) => {
|
|
34
|
+
setViaKb(!!opts?.viaKeyboard);
|
|
35
|
+
setOpenSig(v);
|
|
36
|
+
if (v) {
|
|
37
|
+
// Focus the first item on keyboard-open. The menu mounts via <Show>; we
|
|
38
|
+
// attempt focus now and re-assert in the menu ref's microtask so it lands
|
|
39
|
+
// once the node exists. Skip disabled items (roving-focus contract).
|
|
40
|
+
if (opts?.viaKeyboard) {
|
|
41
|
+
const sel = '[role="menuitem"]:not([aria-disabled="true"])';
|
|
42
|
+
queueMicrotask(() => menu()?.querySelector<HTMLElement>(sel)?.focus());
|
|
43
|
+
menu()?.querySelector<HTMLElement>(sel)?.focus();
|
|
44
|
+
}
|
|
45
|
+
} else if (opts?.returnFocus !== false) {
|
|
46
|
+
// Closing via keyboard/select: return focus to the trigger. The menu
|
|
47
|
+
// unmounts on a microtask (createPresence) and that teardown blurs
|
|
48
|
+
// whatever is focused, so re-assert focus AFTER unmount too.
|
|
49
|
+
const el = trigger();
|
|
50
|
+
el?.focus();
|
|
51
|
+
queueMicrotask(() => el?.focus());
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
return (
|
|
55
|
+
<Ctx.Provider value={{
|
|
56
|
+
open, setOpen, triggerId: createUniqueId(), menuId: createUniqueId(),
|
|
57
|
+
setTrigger, setMenu, trigger, menu, openedViaKeyboard: viaKb,
|
|
58
|
+
}}>
|
|
59
|
+
{props.children}
|
|
60
|
+
</Ctx.Provider>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function DropdownTrigger(props: { as?: AsTag; children?: JSX.Element; class?: string; [k: string]: any }) {
|
|
65
|
+
const ctx = useDropdown();
|
|
66
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
67
|
+
if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter' || e.key === ' ') {
|
|
68
|
+
e.preventDefault();
|
|
69
|
+
ctx.setOpen(true, { viaKeyboard: true });
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
return (
|
|
73
|
+
<As
|
|
74
|
+
as={props.as ?? 'button'}
|
|
75
|
+
ref={ctx.setTrigger}
|
|
76
|
+
id={ctx.triggerId}
|
|
77
|
+
aria-haspopup="menu"
|
|
78
|
+
aria-expanded={ctx.open()}
|
|
79
|
+
aria-controls={ctx.open() ? ctx.menuId : undefined}
|
|
80
|
+
onClick={() => ctx.setOpen(!ctx.open())}
|
|
81
|
+
onKeyDown={onKeyDown}
|
|
82
|
+
class={props.class}
|
|
83
|
+
{...(props.as ? {} : { type: 'button' })}
|
|
84
|
+
>
|
|
85
|
+
{props.children}
|
|
86
|
+
</As>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
8
89
|
|
|
9
90
|
export function DropdownContent(props: { children: JSX.Element; class?: string }) {
|
|
91
|
+
const ctx = useDropdown();
|
|
10
92
|
const config = useChatConfig();
|
|
93
|
+
const presence = createPresence(ctx.open);
|
|
94
|
+
const position = usePosition(ctx.trigger, ctx.menu, { placement: 'bottom-start', gutter: 6 });
|
|
95
|
+
useDismiss({
|
|
96
|
+
enabled: ctx.open,
|
|
97
|
+
onDismiss: (reason) => ctx.setOpen(false, { returnFocus: reason === 'escape' }),
|
|
98
|
+
refs: () => [ctx.trigger(), ctx.menu()],
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const items = () => Array.from(ctx.menu()?.querySelectorAll<HTMLElement>('[role="menuitem"]:not([aria-disabled="true"])') ?? []);
|
|
102
|
+
const focusIndex = (i: number) => {
|
|
103
|
+
const list = items();
|
|
104
|
+
if (!list.length) return;
|
|
105
|
+
const idx = ((i % list.length) + list.length) % list.length;
|
|
106
|
+
list[idx].focus();
|
|
107
|
+
};
|
|
108
|
+
// Resolve the focused item via the menu's own root node. Inside a Shadow DOM
|
|
109
|
+
// (every kitn-* element), `document.activeElement` returns the host element,
|
|
110
|
+
// not the focused menu item, which would break ArrowUp/Down roving focus.
|
|
111
|
+
// `getRootNode().activeElement` correctly returns the active node within the
|
|
112
|
+
// same tree (ShadowRoot or Document).
|
|
113
|
+
const activeItem = () => {
|
|
114
|
+
const root = ctx.menu()?.getRootNode() as Document | ShadowRoot | undefined;
|
|
115
|
+
return (root?.activeElement ?? document.activeElement) as Element | null;
|
|
116
|
+
};
|
|
117
|
+
const currentIndex = () => items().findIndex((el) => el === activeItem());
|
|
118
|
+
|
|
119
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
120
|
+
const list = items();
|
|
121
|
+
switch (e.key) {
|
|
122
|
+
case 'ArrowDown': e.preventDefault(); focusIndex(currentIndex() + 1); break;
|
|
123
|
+
case 'ArrowUp': e.preventDefault(); focusIndex(currentIndex() - 1); break;
|
|
124
|
+
case 'Home': e.preventDefault(); focusIndex(0); break;
|
|
125
|
+
case 'End': e.preventDefault(); focusIndex(list.length - 1); break;
|
|
126
|
+
case 'Tab': ctx.setOpen(false, { returnFocus: false }); break;
|
|
127
|
+
default:
|
|
128
|
+
if (e.key.length === 1 && /\S/.test(e.key)) {
|
|
129
|
+
const start = currentIndex() + 1;
|
|
130
|
+
const lower = e.key.toLowerCase();
|
|
131
|
+
const match = list.findIndex((el, i) => i >= start && (el.textContent ?? '').trim().toLowerCase().startsWith(lower));
|
|
132
|
+
const found = match >= 0 ? match : list.findIndex((el) => (el.textContent ?? '').trim().toLowerCase().startsWith(lower));
|
|
133
|
+
if (found >= 0) { e.preventDefault(); focusIndex(found); }
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
|
|
11
138
|
return (
|
|
12
|
-
<
|
|
13
|
-
<
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
139
|
+
<Show when={presence.present()}>
|
|
140
|
+
<Portal mount={config.portalMount()}>
|
|
141
|
+
<div
|
|
142
|
+
ref={(el) => {
|
|
143
|
+
ctx.setMenu(el); presence.setRef(el);
|
|
144
|
+
// Keyboard-open focuses the first item. setOpen() also attempts this
|
|
145
|
+
// synchronously; this ref-time microtask re-asserts focus once the
|
|
146
|
+
// menu node exists. Skip disabled items.
|
|
147
|
+
if (ctx.openedViaKeyboard()) {
|
|
148
|
+
queueMicrotask(() => el.querySelector<HTMLElement>('[role="menuitem"]:not([aria-disabled="true"])')?.focus());
|
|
149
|
+
}
|
|
150
|
+
}}
|
|
151
|
+
id={ctx.menuId}
|
|
152
|
+
role="menu"
|
|
153
|
+
aria-labelledby={ctx.triggerId}
|
|
154
|
+
tabindex={-1}
|
|
155
|
+
data-expanded={presence.state() === 'open' ? '' : undefined}
|
|
156
|
+
data-closed={presence.state() === 'closed' ? '' : undefined}
|
|
157
|
+
onKeyDown={onKeyDown}
|
|
158
|
+
style={{ position: 'fixed', left: `${position.pos().x}px`, top: `${position.pos().y}px` }}
|
|
159
|
+
class={cn(
|
|
160
|
+
'z-50 min-w-[8rem] rounded-lg bg-card p-1 shadow-lg',
|
|
161
|
+
'animate-in fade-in-0 zoom-in-95 data-[closed]:animate-out data-[closed]:fade-out-0 data-[closed]:zoom-out-95',
|
|
162
|
+
props.class,
|
|
163
|
+
)}
|
|
164
|
+
>
|
|
165
|
+
{props.children}
|
|
166
|
+
</div>
|
|
167
|
+
</Portal>
|
|
168
|
+
</Show>
|
|
17
169
|
);
|
|
18
170
|
}
|
|
19
171
|
|
|
20
|
-
export function DropdownItem(props: { children: JSX.Element; class?: string; onSelect?: () => void
|
|
172
|
+
export function DropdownItem(props: { children: JSX.Element; class?: string; onSelect?: () => void }) {
|
|
173
|
+
const ctx = useDropdown();
|
|
174
|
+
const activate = () => { props.onSelect?.(); ctx.setOpen(false); };
|
|
21
175
|
return (
|
|
22
|
-
<
|
|
176
|
+
<div
|
|
177
|
+
role="menuitem"
|
|
178
|
+
tabindex={-1}
|
|
179
|
+
onClick={activate}
|
|
180
|
+
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); activate(); } }}
|
|
181
|
+
onPointerMove={(e) => (e.currentTarget as HTMLElement).focus()}
|
|
182
|
+
class={cn(
|
|
183
|
+
'flex cursor-pointer items-center rounded-md px-2 py-1.5 text-sm outline-none transition-colors',
|
|
184
|
+
'hover:bg-muted focus:bg-muted',
|
|
185
|
+
props.class,
|
|
186
|
+
)}
|
|
187
|
+
>
|
|
23
188
|
{props.children}
|
|
24
|
-
</
|
|
189
|
+
</div>
|
|
25
190
|
);
|
|
26
191
|
}
|
package/src/ui/hover-card.tsx
CHANGED
|
@@ -1,48 +1,169 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
createContext, useContext, createSignal, Show, splitProps, onCleanup,
|
|
3
|
+
type JSX, type Accessor,
|
|
4
|
+
} from 'solid-js';
|
|
5
|
+
import { Portal } from 'solid-js/web';
|
|
3
6
|
import { cn } from '../utils/cn';
|
|
4
7
|
import { useChatConfig } from '../primitives/chat-config';
|
|
8
|
+
import { createPresence, usePosition, useDismiss, As } from './overlay';
|
|
5
9
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
<KHoverCard.Content class={cn('z-50 w-64 rounded-lg bg-card p-4 shadow-lg animate-in fade-in-0 zoom-in-95', local.class)}>
|
|
16
|
-
<KHoverCard.Arrow />
|
|
17
|
-
{local.children}
|
|
18
|
-
</KHoverCard.Content>
|
|
19
|
-
</KHoverCard.Portal>
|
|
20
|
-
</KHoverCard>
|
|
21
|
-
);
|
|
10
|
+
interface HoverCardCtx {
|
|
11
|
+
open: Accessor<boolean>;
|
|
12
|
+
enter: () => void;
|
|
13
|
+
leave: () => void;
|
|
14
|
+
close: () => void;
|
|
15
|
+
setTrigger: (el: HTMLElement) => void;
|
|
16
|
+
setContent: (el: HTMLElement) => void;
|
|
17
|
+
trigger: Accessor<HTMLElement | undefined>;
|
|
18
|
+
content: Accessor<HTMLElement | undefined>;
|
|
22
19
|
}
|
|
20
|
+
const Ctx = createContext<HoverCardCtx>();
|
|
21
|
+
const useHoverCard = () => {
|
|
22
|
+
const c = useContext(Ctx);
|
|
23
|
+
if (!c) throw new Error('HoverCard parts must be used within <HoverCardRoot>');
|
|
24
|
+
return c;
|
|
25
|
+
};
|
|
23
26
|
|
|
24
|
-
// Compound primitives for custom layouts (e.g. Source component)
|
|
25
27
|
export interface HoverCardRootProps { children: JSX.Element; openDelay?: number; closeDelay?: number; }
|
|
26
28
|
|
|
27
29
|
export function HoverCardRoot(props: HoverCardRootProps) {
|
|
28
|
-
|
|
30
|
+
const [open, setOpen] = createSignal(false);
|
|
31
|
+
const [trigger, setTrigger] = createSignal<HTMLElement>();
|
|
32
|
+
const [content, setContent] = createSignal<HTMLElement>();
|
|
33
|
+
let timer: number | undefined;
|
|
34
|
+
|
|
35
|
+
// ONE shared timer drives both trigger and content. Entering either cancels
|
|
36
|
+
// any pending close and schedules an open; leaving either cancels any pending
|
|
37
|
+
// open and schedules a close. Because the pointer transit trigger -> content
|
|
38
|
+
// fires leave() then enter() against the SAME timer, the close is cancelled
|
|
39
|
+
// before it can run, so the card never flickers and there are no stale-timer
|
|
40
|
+
// sporadics (the HC-1 fix).
|
|
41
|
+
const enter = () => {
|
|
42
|
+
clearTimeout(timer);
|
|
43
|
+
timer = window.setTimeout(() => setOpen(true), props.openDelay ?? 0);
|
|
44
|
+
};
|
|
45
|
+
const leave = () => {
|
|
46
|
+
clearTimeout(timer);
|
|
47
|
+
// closeDelay default is 300ms (Radix-style) as a belt-and-suspenders fallback
|
|
48
|
+
// for diagonal pointer escapes that miss the transparent safe bridge.
|
|
49
|
+
timer = window.setTimeout(() => setOpen(false), props.closeDelay ?? 300);
|
|
50
|
+
};
|
|
51
|
+
const close = () => { clearTimeout(timer); setOpen(false); };
|
|
52
|
+
onCleanup(() => clearTimeout(timer));
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<Ctx.Provider value={{
|
|
56
|
+
open, enter, leave, close,
|
|
57
|
+
setTrigger, setContent,
|
|
58
|
+
trigger, content,
|
|
59
|
+
}}>
|
|
60
|
+
{props.children}
|
|
61
|
+
</Ctx.Provider>
|
|
62
|
+
);
|
|
29
63
|
}
|
|
30
64
|
|
|
31
65
|
export interface HoverCardTriggerProps { children: JSX.Element; }
|
|
32
66
|
|
|
33
67
|
export function HoverCardTrigger(props: HoverCardTriggerProps) {
|
|
34
|
-
|
|
68
|
+
const ctx = useHoverCard();
|
|
69
|
+
return (
|
|
70
|
+
<As
|
|
71
|
+
as="span"
|
|
72
|
+
ref={ctx.setTrigger}
|
|
73
|
+
onPointerEnter={ctx.enter}
|
|
74
|
+
onPointerLeave={ctx.leave}
|
|
75
|
+
onFocusIn={ctx.enter}
|
|
76
|
+
onFocusOut={ctx.leave}
|
|
77
|
+
>
|
|
78
|
+
{props.children}
|
|
79
|
+
</As>
|
|
80
|
+
);
|
|
35
81
|
}
|
|
36
82
|
|
|
37
83
|
export interface HoverCardContentProps { children: JSX.Element; class?: string; }
|
|
38
84
|
|
|
85
|
+
// Visual gap between trigger and the visible card. Also the depth of the
|
|
86
|
+
// transparent safe bridge so the pointer never crosses "empty" space.
|
|
87
|
+
const GUTTER = 8;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Returns the CSS padding property that, set to `gutter`px on the OUTER floating
|
|
91
|
+
* shell, recreates the visual gap as a transparent safe area on the
|
|
92
|
+
* trigger-facing side. The outer shell is placed flush (gutter: 0) so the
|
|
93
|
+
* padding bridges the gap while keeping the inner card the same distance away.
|
|
94
|
+
*
|
|
95
|
+
* Placement strings from @floating-ui/dom (post flip/shift) may carry a
|
|
96
|
+
* '-start'/'-end' alignment suffix; we split on '-' and key on the side.
|
|
97
|
+
* bottom* -> padding-top, top* -> padding-bottom,
|
|
98
|
+
* left* -> padding-right, right* -> padding-left
|
|
99
|
+
*/
|
|
100
|
+
function gapPaddingStyle(placement: string, gutter: number): JSX.CSSProperties {
|
|
101
|
+
const side = placement.split('-')[0];
|
|
102
|
+
const prop: Record<string, keyof JSX.CSSProperties> = {
|
|
103
|
+
bottom: 'padding-top',
|
|
104
|
+
top: 'padding-bottom',
|
|
105
|
+
left: 'padding-right',
|
|
106
|
+
right: 'padding-left',
|
|
107
|
+
};
|
|
108
|
+
return { [prop[side] ?? 'padding-top']: `${gutter}px` };
|
|
109
|
+
}
|
|
110
|
+
|
|
39
111
|
export function HoverCardContent(props: HoverCardContentProps) {
|
|
112
|
+
const ctx = useHoverCard();
|
|
40
113
|
const config = useChatConfig();
|
|
114
|
+
const presence = createPresence(ctx.open);
|
|
115
|
+
// gutter: 0 places the outer shell flush with the trigger; the visual gap is
|
|
116
|
+
// recreated by transparent padding (gapPaddingStyle) so the hit area bridges
|
|
117
|
+
// it and a straight trigger->content transit never leaves a hot zone.
|
|
118
|
+
const position = usePosition(ctx.trigger, ctx.content, { placement: 'bottom', gutter: 0 });
|
|
119
|
+
useDismiss({ enabled: ctx.open, onDismiss: (reason) => (reason === 'escape' ? ctx.close() : ctx.leave()), refs: () => [ctx.trigger(), ctx.content()] });
|
|
120
|
+
|
|
121
|
+
return (
|
|
122
|
+
<Show when={presence.present()}>
|
|
123
|
+
<Portal mount={config.portalMount()}>
|
|
124
|
+
{/* Outer shell: positioning + the transparent safe bridge + hot zone. */}
|
|
125
|
+
<div
|
|
126
|
+
ref={(el) => { ctx.setContent(el); presence.setRef(el); }}
|
|
127
|
+
data-hovercard-content
|
|
128
|
+
onPointerEnter={ctx.enter}
|
|
129
|
+
onPointerLeave={ctx.leave}
|
|
130
|
+
onFocusIn={ctx.enter}
|
|
131
|
+
onFocusOut={ctx.leave}
|
|
132
|
+
style={{
|
|
133
|
+
position: 'fixed',
|
|
134
|
+
left: `${position.pos().x}px`,
|
|
135
|
+
top: `${position.pos().y}px`,
|
|
136
|
+
background: 'transparent',
|
|
137
|
+
...gapPaddingStyle(position.pos().placement, GUTTER),
|
|
138
|
+
}}
|
|
139
|
+
class="z-50"
|
|
140
|
+
>
|
|
141
|
+
{/* Inner card: all visual + animation classes and the presence state. */}
|
|
142
|
+
<div
|
|
143
|
+
data-expanded={presence.state() === 'open' ? '' : undefined}
|
|
144
|
+
data-closed={presence.state() === 'closed' ? '' : undefined}
|
|
145
|
+
class={cn(
|
|
146
|
+
'rounded-lg bg-card shadow-lg',
|
|
147
|
+
'animate-in fade-in-0 zoom-in-95 data-[closed]:animate-out data-[closed]:fade-out-0 data-[closed]:zoom-out-95',
|
|
148
|
+
props.class,
|
|
149
|
+
)}
|
|
150
|
+
>
|
|
151
|
+
{props.children}
|
|
152
|
+
</div>
|
|
153
|
+
</div>
|
|
154
|
+
</Portal>
|
|
155
|
+
</Show>
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export interface HoverCardProps { trigger: JSX.Element; children: JSX.Element; class?: string; openDelay?: number; closeDelay?: number; }
|
|
160
|
+
|
|
161
|
+
export function HoverCard(props: HoverCardProps) {
|
|
162
|
+
const [local] = splitProps(props, ['trigger', 'children', 'class', 'openDelay', 'closeDelay']);
|
|
41
163
|
return (
|
|
42
|
-
<
|
|
43
|
-
<
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
</KHoverCard.Portal>
|
|
164
|
+
<HoverCardRoot openDelay={local.openDelay} closeDelay={local.closeDelay}>
|
|
165
|
+
<HoverCardTrigger>{local.trigger}</HoverCardTrigger>
|
|
166
|
+
<HoverCardContent class={cn('w-64 p-4', local.class)}>{local.children}</HoverCardContent>
|
|
167
|
+
</HoverCardRoot>
|
|
47
168
|
);
|
|
48
169
|
}
|