@kitnai/chat 0.3.1 → 0.5.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 (119) hide show
  1. package/README.md +35 -5
  2. package/dist/custom-elements.json +2969 -0
  3. package/dist/kitn-chat.es.js +52 -39
  4. package/dist/llms/llms-full.txt +718 -0
  5. package/dist/llms/llms.txt +104 -0
  6. package/dist/theme.tokens.css +137 -0
  7. package/frameworks/react/index.tsx +584 -0
  8. package/frameworks/react/runtime.tsx +94 -0
  9. package/llms-full.txt +718 -0
  10. package/llms.txt +104 -0
  11. package/package.json +53 -6
  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/chat-thread.tsx +217 -0
  16. package/src/components/checkpoint.tsx +7 -3
  17. package/src/components/context.tsx +14 -18
  18. package/src/components/conversation-item.tsx +1 -1
  19. package/src/components/conversation-list.tsx +5 -4
  20. package/src/components/message-skills.tsx +1 -1
  21. package/src/components/message.tsx +1 -0
  22. package/src/components/model-switcher.tsx +3 -3
  23. package/src/components/prompt-input.tsx +20 -2
  24. package/src/components/reasoning.tsx +2 -2
  25. package/src/components/scroll-button.tsx +1 -0
  26. package/src/components/slash-command.tsx +17 -8
  27. package/src/components/source.tsx +2 -2
  28. package/src/components/thinking-bar.tsx +2 -2
  29. package/src/components/tool.tsx +17 -6
  30. package/src/components/voice-input.tsx +5 -1
  31. package/src/elements/attachments.tsx +132 -0
  32. package/src/elements/chain-of-thought.tsx +45 -0
  33. package/src/elements/chat-scope-picker.tsx +36 -0
  34. package/src/elements/chat-workspace.tsx +122 -0
  35. package/src/elements/chat.tsx +31 -228
  36. package/src/elements/checkpoint.tsx +43 -0
  37. package/src/elements/code-block.tsx +42 -0
  38. package/src/elements/compiled.css +1 -1
  39. package/src/elements/context-meter.tsx +71 -0
  40. package/src/elements/conversation-list.tsx +6 -0
  41. package/src/elements/default-input.tsx +22 -1
  42. package/src/elements/define.tsx +98 -12
  43. package/src/elements/element-types.d.ts +444 -0
  44. package/src/elements/empty.tsx +29 -0
  45. package/src/elements/feedback-bar.tsx +33 -0
  46. package/src/elements/file-upload.tsx +44 -0
  47. package/src/elements/image.tsx +32 -0
  48. package/src/elements/kitn-attachments.stories.tsx +181 -0
  49. package/src/elements/kitn-chain-of-thought.stories.tsx +75 -0
  50. package/src/elements/kitn-chat-scope-picker.stories.tsx +72 -0
  51. package/src/elements/kitn-chat-workspace.stories.tsx +195 -0
  52. package/src/elements/kitn-checkpoint.stories.tsx +71 -0
  53. package/src/elements/kitn-code-block.stories.tsx +82 -0
  54. package/src/elements/kitn-context-meter.stories.tsx +85 -0
  55. package/src/elements/kitn-empty.stories.tsx +110 -0
  56. package/src/elements/kitn-feedback-bar.stories.tsx +73 -0
  57. package/src/elements/kitn-file-upload.stories.tsx +81 -0
  58. package/src/elements/kitn-image.stories.tsx +70 -0
  59. package/src/elements/kitn-loader.stories.tsx +87 -0
  60. package/src/elements/kitn-markdown.stories.tsx +75 -0
  61. package/src/elements/kitn-message-skills.stories.tsx +74 -0
  62. package/src/elements/kitn-message.stories.tsx +105 -0
  63. package/src/elements/kitn-model-switcher.stories.tsx +80 -0
  64. package/src/elements/kitn-prompt-input.stories.tsx +74 -16
  65. package/src/elements/kitn-prompt-suggestions.stories.tsx +157 -0
  66. package/src/elements/kitn-reasoning.stories.tsx +76 -0
  67. package/src/elements/kitn-response-stream.stories.tsx +79 -0
  68. package/src/elements/kitn-source-list.stories.tsx +77 -0
  69. package/src/elements/kitn-source.stories.tsx +87 -0
  70. package/src/elements/kitn-text-shimmer.stories.tsx +63 -0
  71. package/src/elements/kitn-thinking-bar.stories.tsx +72 -0
  72. package/src/elements/kitn-tool.stories.tsx +88 -0
  73. package/src/elements/kitn-voice-input.stories.tsx +87 -0
  74. package/src/elements/loader.tsx +25 -0
  75. package/src/elements/markdown.tsx +38 -0
  76. package/src/elements/message-skills.tsx +22 -0
  77. package/src/elements/message.tsx +125 -0
  78. package/src/elements/model-switcher.tsx +35 -0
  79. package/src/elements/prompt-input.tsx +83 -7
  80. package/src/elements/prompt-suggestions.tsx +58 -0
  81. package/src/elements/reasoning.tsx +50 -0
  82. package/src/elements/register.ts +32 -0
  83. package/src/elements/response-stream.tsx +40 -0
  84. package/src/elements/source.tsx +67 -0
  85. package/src/elements/styles.css +14 -0
  86. package/src/elements/text-shimmer.tsx +28 -0
  87. package/src/elements/thinking-bar.tsx +34 -0
  88. package/src/elements/tool.tsx +23 -0
  89. package/src/elements/voice-input.tsx +41 -0
  90. package/src/index.ts +0 -1
  91. package/src/primitives/chat-config.tsx +3 -3
  92. package/src/stories/docs/Accessibility.mdx +119 -0
  93. package/src/stories/docs/ForAIAgents.mdx +93 -0
  94. package/src/stories/docs/GettingStarted.mdx +2 -2
  95. package/src/stories/docs/Installation.mdx +29 -2
  96. package/src/stories/docs/Integrations.mdx +417 -15
  97. package/src/stories/docs/Introduction.mdx +17 -8
  98. package/src/stories/docs/Theming.mdx +1 -1
  99. package/src/stories/pattern-centered-conversation.stories.tsx +93 -0
  100. package/src/stories/pattern-docked-widget.stories.tsx +93 -0
  101. package/src/stories/pattern-empty-state.stories.tsx +76 -0
  102. package/src/stories/typography.stories.tsx +78 -0
  103. package/src/ui/button.tsx +1 -1
  104. package/src/ui/collapsible.stories.tsx +70 -0
  105. package/src/ui/collapsible.tsx +119 -8
  106. package/src/ui/dropdown.stories.tsx +60 -0
  107. package/src/ui/dropdown.tsx +177 -12
  108. package/src/ui/hover-card.stories.tsx +78 -0
  109. package/src/ui/hover-card.tsx +147 -26
  110. package/src/ui/overlay.stories.tsx +115 -0
  111. package/src/ui/overlay.tsx +151 -0
  112. package/src/ui/scroll-area.stories.tsx +51 -0
  113. package/src/ui/textarea.stories.tsx +77 -0
  114. package/src/ui/textarea.tsx +1 -1
  115. package/src/ui/tooltip.stories.tsx +1 -1
  116. package/src/ui/tooltip.tsx +59 -13
  117. package/src/utils/cn.ts +19 -1
  118. package/theme.css +76 -43
  119. package/src/ui/dialog.tsx +0 -21
@@ -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
  }
@@ -0,0 +1,78 @@
1
+ import type { Meta, StoryObj } from 'storybook-solidjs-vite';
2
+ import { HoverCard } from './hover-card';
3
+ import { Button } from './button';
4
+
5
+ const meta = {
6
+ title: 'UI/HoverCard',
7
+ component: HoverCard,
8
+ tags: ['autodocs'],
9
+ parameters: {
10
+ layout: 'padded',
11
+ docs: {
12
+ description: {
13
+ component: [
14
+ 'A floating card that opens when its trigger is hovered or focused, built on the kit\'s DIY overlay core (positioning + dismiss + presence — no third-party dependency). Portals into the active shadow root so it never clips, and a transparent "safe bridge" keeps it open while the pointer travels from trigger to card.',
15
+ '**When to use:** to reveal supplementary, non-essential context on hover — a user/profile preview, a link or citation preview, an attachment summary. For an actionable menu use `Dropdown`; for a one-line label use `Tooltip`.',
16
+ '**How to use:** pass the trigger element as `trigger` and the card body as `children`. Tune `openDelay` / `closeDelay` (ms) to taste.',
17
+ ].join('\n\n'),
18
+ },
19
+ },
20
+ },
21
+ argTypes: {
22
+ trigger: { control: false, description: 'The element that opens the card on hover/focus.' },
23
+ children: { control: false, description: 'The card contents.' },
24
+ openDelay: { control: 'number', description: 'Delay (ms) before the card opens. Default 0.' },
25
+ closeDelay: { control: 'number', description: 'Delay (ms) before the card closes after the pointer leaves. Default 300.' },
26
+ class: { control: 'text', description: 'Extra classes applied to the card body.' },
27
+ },
28
+ args: {
29
+ trigger: <Button variant="outline">@ada</Button>,
30
+ children: (
31
+ <div class="flex gap-3">
32
+ <div class="flex size-10 shrink-0 items-center justify-center rounded-full bg-primary text-sm font-medium text-primary-foreground">AL</div>
33
+ <div class="space-y-1">
34
+ <p class="text-sm font-medium text-foreground">Ada Lovelace</p>
35
+ <p class="text-xs text-muted-foreground">Wrote the first algorithm intended for a machine. Joined in 1843.</p>
36
+ </div>
37
+ </div>
38
+ ),
39
+ },
40
+ render: (args) => <HoverCard {...args} />,
41
+ } satisfies Meta<typeof HoverCard>;
42
+
43
+ export default meta;
44
+ type Story = StoryObj<typeof meta>;
45
+
46
+ const IMPORT = `import { HoverCard } from '@kitnai/chat';`;
47
+ const src = (code: string) => ({
48
+ parameters: { docs: { source: { code: `${IMPORT}\n\n${code}`, language: 'tsx' } } },
49
+ });
50
+
51
+ /** Hover (or focus) the trigger to reveal a profile preview card. */
52
+ export const Playground: Story = {
53
+ ...src(`<HoverCard trigger={<Button variant="outline">@ada</Button>}>
54
+ <ProfilePreview name="Ada Lovelace" />
55
+ </HoverCard>`),
56
+ };
57
+
58
+ /** A link-preview card, the way it might appear inline in an assistant message. */
59
+ export const LinkPreview: Story = {
60
+ render: () => (
61
+ <p class="max-w-md text-sm text-foreground">
62
+ See the{' '}
63
+ <HoverCard
64
+ trigger={<a href="#" class="font-medium text-primary underline underline-offset-2">MDN reference</a>}
65
+ >
66
+ <div class="space-y-1">
67
+ <p class="text-sm font-medium text-foreground">Custom elements — MDN</p>
68
+ <p class="text-xs text-muted-foreground">developer.mozilla.org</p>
69
+ <p class="text-xs text-muted-foreground">Define your own HTML elements with the CustomElementRegistry.</p>
70
+ </div>
71
+ </HoverCard>{' '}
72
+ for the full custom-elements API.
73
+ </p>
74
+ ),
75
+ ...src(`<HoverCard trigger={<a href="...">MDN reference</a>}>
76
+ <LinkCard title="Custom elements — MDN" host="developer.mozilla.org" />
77
+ </HoverCard>`),
78
+ };
@@ -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
  }
@@ -0,0 +1,115 @@
1
+ import type { Meta, StoryObj } from 'storybook-solidjs-vite';
2
+ import { createSignal, Show } from 'solid-js';
3
+ import { Portal } from 'solid-js/web';
4
+ import { createPresence, usePosition, useDismiss } from './overlay';
5
+ import { buttonVariants } from './button';
6
+
7
+ /**
8
+ * `overlay.tsx` is not a component — it's the small DIY toolkit that every
9
+ * floating surface in the kit (Tooltip, HoverCard, Dropdown) is built from,
10
+ * replacing the former third-party UI dependency. Three primitives:
11
+ *
12
+ * - `usePosition(ref, floating, opts)` — anchors `floating` to `ref` via
13
+ * @floating-ui/dom with flip/shift, tracking it on scroll/resize.
14
+ * - `createPresence(open)` — keeps a node mounted through its CSS exit
15
+ * animation, then unmounts on `animationend`.
16
+ * - `useDismiss({ enabled, onDismiss, refs })` — Escape + outside-pointerdown
17
+ * dismissal (no scroll lock).
18
+ *
19
+ * Plus an `As` polymorphic helper for trigger elements.
20
+ */
21
+ const meta: Meta = {
22
+ title: 'UI/Overlay Core',
23
+ tags: ['autodocs'],
24
+ parameters: {
25
+ layout: 'padded',
26
+ docs: {
27
+ description: {
28
+ component: [
29
+ "The shared, dependency-free foundation behind every floating surface in the kit — `Tooltip`, `HoverCard`, and `Dropdown` are all assembled from these primitives. It's a toolkit of hooks, not a renderable component.",
30
+ '**`usePosition(ref, floating, opts)`** anchors a floating node to a trigger via `@floating-ui/dom` (flip/shift, fixed strategy, tracks on scroll/resize). **`createPresence(open)`** keeps the node mounted through its CSS exit animation. **`useDismiss({ enabled, onDismiss, refs })`** handles Escape + outside-click. An **`As`** helper renders a polymorphic trigger.',
31
+ '**When to use:** only when you need a floating surface the prebuilt components don\'t cover. Reach for `Tooltip` / `HoverCard` / `Dropdown` first — they already wire these together with the correct ARIA and focus behavior.',
32
+ 'The demo below composes all three into a minimal popover; everything portals into the active shadow root via `ChatConfig`.',
33
+ ].join('\n\n'),
34
+ },
35
+ },
36
+ },
37
+ };
38
+
39
+ export default meta;
40
+ type Story = StoryObj<typeof meta>;
41
+
42
+ function PopoverDemo() {
43
+ const [open, setOpen] = createSignal(false);
44
+ const [trigger, setTrigger] = createSignal<HTMLElement>();
45
+ const [content, setContent] = createSignal<HTMLElement>();
46
+ const presence = createPresence(open);
47
+ const position = usePosition(trigger, content, { placement: 'bottom-start', gutter: 6 });
48
+ useDismiss({ enabled: open, onDismiss: () => setOpen(false), refs: () => [trigger(), content()] });
49
+
50
+ return (
51
+ <>
52
+ <button
53
+ ref={setTrigger}
54
+ type="button"
55
+ class={buttonVariants({ variant: 'outline' })}
56
+ onClick={() => setOpen(!open())}
57
+ >
58
+ {open() ? 'Close' : 'Open'} popover
59
+ </button>
60
+ <Show when={presence.present()}>
61
+ <Portal>
62
+ <div
63
+ ref={(el) => { setContent(el); presence.setRef(el); }}
64
+ data-expanded={presence.state() === 'open' ? '' : undefined}
65
+ data-closed={presence.state() === 'closed' ? '' : undefined}
66
+ style={{ position: 'fixed', left: `${position.pos().x}px`, top: `${position.pos().y}px` }}
67
+ class="z-50 w-64 rounded-lg bg-card p-3 text-sm text-foreground shadow-lg animate-in fade-in-0 zoom-in-95 data-[closed]:animate-out data-[closed]:fade-out-0 data-[closed]:zoom-out-95"
68
+ >
69
+ Positioned by <code class="text-xs">usePosition</code>, kept mounted through its
70
+ exit animation by <code class="text-xs">createPresence</code>, and closed on
71
+ Escape / outside-click by <code class="text-xs">useDismiss</code>.
72
+ </div>
73
+ </Portal>
74
+ </Show>
75
+ </>
76
+ );
77
+ }
78
+
79
+ /** A minimal popover hand-built from the three overlay primitives. Click to toggle; click outside or press Escape to dismiss. */
80
+ export const MinimalPopover: Story = {
81
+ render: () => <PopoverDemo />,
82
+ parameters: {
83
+ docs: {
84
+ source: {
85
+ code: `import { createPresence, usePosition, useDismiss } from '@kitnai/chat';
86
+
87
+ function PopoverDemo() {
88
+ const [open, setOpen] = createSignal(false);
89
+ const [trigger, setTrigger] = createSignal<HTMLElement>();
90
+ const [content, setContent] = createSignal<HTMLElement>();
91
+ const presence = createPresence(open);
92
+ const position = usePosition(trigger, content, { placement: 'bottom-start', gutter: 6 });
93
+ useDismiss({ enabled: open, onDismiss: () => setOpen(false), refs: () => [trigger(), content()] });
94
+
95
+ return (
96
+ <>
97
+ <button ref={setTrigger} onClick={() => setOpen(!open())}>Toggle</button>
98
+ <Show when={presence.present()}>
99
+ <Portal>
100
+ <div
101
+ ref={(el) => { setContent(el); presence.setRef(el); }}
102
+ style={{ position: 'fixed', left: \`\${position.pos().x}px\`, top: \`\${position.pos().y}px\` }}
103
+ >
104
+ …content…
105
+ </div>
106
+ </Portal>
107
+ </Show>
108
+ </>
109
+ );
110
+ }`,
111
+ language: 'tsx',
112
+ },
113
+ },
114
+ },
115
+ };