@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.
Files changed (106) hide show
  1. package/README.md +11 -0
  2. package/dist/custom-elements.json +2494 -0
  3. package/dist/kitn-chat.es.js +52 -39
  4. package/dist/llms/llms-full.txt +667 -0
  5. package/dist/llms/llms.txt +104 -0
  6. package/dist/theme.tokens.css +133 -0
  7. package/frameworks/react/index.tsx +530 -0
  8. package/frameworks/react/runtime.tsx +94 -0
  9. package/llms-full.txt +667 -0
  10. package/llms.txt +104 -0
  11. package/package.json +34 -5
  12. package/src/components/attachments.tsx +4 -2
  13. package/src/components/chain-of-thought.tsx +1 -1
  14. package/src/components/chat-scope-picker.tsx +2 -2
  15. package/src/components/checkpoint.tsx +7 -3
  16. package/src/components/context.tsx +14 -18
  17. package/src/components/conversation-item.tsx +1 -1
  18. package/src/components/conversation-list.tsx +5 -4
  19. package/src/components/message-skills.tsx +1 -1
  20. package/src/components/message.tsx +1 -0
  21. package/src/components/model-switcher.tsx +3 -3
  22. package/src/components/prompt-input.tsx +15 -2
  23. package/src/components/reasoning.tsx +2 -2
  24. package/src/components/scroll-button.tsx +1 -0
  25. package/src/components/slash-command.tsx +17 -8
  26. package/src/components/source.tsx +2 -2
  27. package/src/components/thinking-bar.tsx +2 -2
  28. package/src/components/tool.tsx +17 -6
  29. package/src/components/voice-input.tsx +5 -1
  30. package/src/elements/attachments.tsx +132 -0
  31. package/src/elements/chain-of-thought.tsx +45 -0
  32. package/src/elements/chat-scope-picker.tsx +36 -0
  33. package/src/elements/chat.tsx +51 -7
  34. package/src/elements/checkpoint.tsx +43 -0
  35. package/src/elements/code-block.tsx +42 -0
  36. package/src/elements/compiled.css +1 -1
  37. package/src/elements/context-meter.tsx +71 -0
  38. package/src/elements/conversation-list.tsx +6 -0
  39. package/src/elements/default-input.tsx +22 -1
  40. package/src/elements/define.tsx +102 -13
  41. package/src/elements/element-types.d.ts +404 -0
  42. package/src/elements/empty.tsx +29 -0
  43. package/src/elements/feedback-bar.tsx +33 -0
  44. package/src/elements/file-upload.tsx +44 -0
  45. package/src/elements/image.tsx +32 -0
  46. package/src/elements/kitn-attachments.stories.tsx +181 -0
  47. package/src/elements/kitn-chain-of-thought.stories.tsx +75 -0
  48. package/src/elements/kitn-chat-scope-picker.stories.tsx +72 -0
  49. package/src/elements/kitn-checkpoint.stories.tsx +71 -0
  50. package/src/elements/kitn-code-block.stories.tsx +82 -0
  51. package/src/elements/kitn-context-meter.stories.tsx +85 -0
  52. package/src/elements/kitn-empty.stories.tsx +110 -0
  53. package/src/elements/kitn-feedback-bar.stories.tsx +73 -0
  54. package/src/elements/kitn-file-upload.stories.tsx +81 -0
  55. package/src/elements/kitn-image.stories.tsx +70 -0
  56. package/src/elements/kitn-loader.stories.tsx +87 -0
  57. package/src/elements/kitn-markdown.stories.tsx +75 -0
  58. package/src/elements/kitn-message-skills.stories.tsx +74 -0
  59. package/src/elements/kitn-message.stories.tsx +105 -0
  60. package/src/elements/kitn-model-switcher.stories.tsx +80 -0
  61. package/src/elements/kitn-prompt-input.stories.tsx +74 -16
  62. package/src/elements/kitn-prompt-suggestions.stories.tsx +157 -0
  63. package/src/elements/kitn-reasoning.stories.tsx +76 -0
  64. package/src/elements/kitn-response-stream.stories.tsx +79 -0
  65. package/src/elements/kitn-source-list.stories.tsx +77 -0
  66. package/src/elements/kitn-source.stories.tsx +87 -0
  67. package/src/elements/kitn-text-shimmer.stories.tsx +63 -0
  68. package/src/elements/kitn-thinking-bar.stories.tsx +72 -0
  69. package/src/elements/kitn-tool.stories.tsx +88 -0
  70. package/src/elements/kitn-voice-input.stories.tsx +87 -0
  71. package/src/elements/loader.tsx +25 -0
  72. package/src/elements/markdown.tsx +38 -0
  73. package/src/elements/message-skills.tsx +22 -0
  74. package/src/elements/message.tsx +125 -0
  75. package/src/elements/model-switcher.tsx +35 -0
  76. package/src/elements/prompt-input.tsx +83 -7
  77. package/src/elements/prompt-suggestions.tsx +58 -0
  78. package/src/elements/reasoning.tsx +50 -0
  79. package/src/elements/register.ts +31 -0
  80. package/src/elements/response-stream.tsx +40 -0
  81. package/src/elements/source.tsx +67 -0
  82. package/src/elements/text-shimmer.tsx +28 -0
  83. package/src/elements/thinking-bar.tsx +34 -0
  84. package/src/elements/tool.tsx +23 -0
  85. package/src/elements/voice-input.tsx +41 -0
  86. package/src/index.ts +0 -1
  87. package/src/primitives/chat-config.tsx +2 -2
  88. package/src/stories/docs/Accessibility.mdx +119 -0
  89. package/src/stories/docs/ForAIAgents.mdx +93 -0
  90. package/src/stories/docs/GettingStarted.mdx +2 -2
  91. package/src/stories/docs/Installation.mdx +2 -2
  92. package/src/stories/docs/Integrations.mdx +415 -15
  93. package/src/stories/docs/Introduction.mdx +5 -5
  94. package/src/stories/docs/Theming.mdx +1 -1
  95. package/src/stories/typography.stories.tsx +78 -0
  96. package/src/ui/button.tsx +1 -1
  97. package/src/ui/collapsible.tsx +119 -8
  98. package/src/ui/dropdown.tsx +177 -12
  99. package/src/ui/hover-card.tsx +147 -26
  100. package/src/ui/overlay.tsx +151 -0
  101. package/src/ui/textarea.tsx +1 -1
  102. package/src/ui/tooltip.stories.tsx +1 -1
  103. package/src/ui/tooltip.tsx +59 -13
  104. package/src/utils/cn.ts +19 -1
  105. package/theme.css +72 -43
  106. 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&nbsp;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 &amp; 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-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
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: {
@@ -1,14 +1,125 @@
1
- import { Collapsible as KCollapsible } from '@kobalte/core/collapsible';
2
- import { type JSX } from 'solid-js';
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
- export const Collapsible = KCollapsible;
6
- export const CollapsibleTrigger = KCollapsible.Trigger;
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: JSX.Element; class?: string }) {
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
- <KCollapsible.Content class={cn('overflow-hidden', props.class)}>
11
- {props.children}
12
- </KCollapsible.Content>
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
  }
@@ -1,26 +1,191 @@
1
- import { DropdownMenu as KDropdown } from '@kobalte/core/dropdown-menu';
2
- import { type JSX } from 'solid-js';
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
- export const Dropdown = KDropdown;
7
- export const DropdownTrigger = KDropdown.Trigger;
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
- <KDropdown.Portal mount={config.portalMount()}>
13
- <KDropdown.Content class={cn('z-50 min-w-[8rem] rounded-lg bg-card p-1 shadow-lg animate-in fade-in-0 zoom-in-95', props.class)}>
14
- {props.children}
15
- </KDropdown.Content>
16
- </KDropdown.Portal>
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
- <KDropdown.Item class={cn('flex cursor-pointer items-center rounded-md px-2 py-1.5 text-sm outline-none hover:bg-muted transition-colors', props.class)} onSelect={props.onSelect}>
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
- </KDropdown.Item>
189
+ </div>
25
190
  );
26
191
  }
@@ -1,48 +1,169 @@
1
- import { HoverCard as KHoverCard } from '@kobalte/core/hover-card';
2
- import { type JSX, splitProps } from 'solid-js';
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
- export interface HoverCardProps { trigger: JSX.Element; children: JSX.Element; class?: string; openDelay?: number; closeDelay?: number; }
7
-
8
- export function HoverCard(props: HoverCardProps) {
9
- const [local] = splitProps(props, ['trigger', 'children', 'class', 'openDelay', 'closeDelay']);
10
- const config = useChatConfig();
11
- return (
12
- <KHoverCard openDelay={local.openDelay} closeDelay={local.closeDelay}>
13
- <KHoverCard.Trigger as="span">{local.trigger}</KHoverCard.Trigger>
14
- <KHoverCard.Portal mount={config.portalMount()}>
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
- return <KHoverCard openDelay={props.openDelay} closeDelay={props.closeDelay}>{props.children}</KHoverCard>;
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
- return <KHoverCard.Trigger as="span">{props.children}</KHoverCard.Trigger>;
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
- <KHoverCard.Portal mount={config.portalMount()}>
43
- <KHoverCard.Content class={cn('z-50 rounded-lg bg-card shadow-lg animate-in fade-in-0 zoom-in-95', props.class)}>
44
- {props.children}
45
- </KHoverCard.Content>
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
  }