@kitnai/chat 0.7.0 → 0.8.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 +9 -9
- package/dist/custom-elements.json +1626 -883
- package/dist/kitn-chat.es.js +36 -36
- package/dist/llms/llms-full.txt +303 -142
- package/dist/llms/llms.txt +18 -18
- package/dist/schemas/card-envelope.schema.json +14 -0
- package/dist/schemas/card-event.schema.json +12 -0
- package/dist/schemas/confirm.schema.json +65 -0
- package/dist/schemas/embed.schema.json +65 -0
- package/dist/schemas/form.result.schema.json +7 -0
- package/dist/schemas/form.schema.json +33 -0
- package/dist/schemas/link.schema.json +56 -0
- package/dist/schemas/task-list.result.schema.json +16 -0
- package/dist/schemas/task-list.schema.json +78 -0
- package/dist/theme.tokens.css +65 -65
- package/dist/tsx-B8rCNbgL.js +1 -0
- package/dist/typescript-RycA9KXf.js +1 -0
- package/frameworks/react/index.tsx +356 -189
- package/frameworks/react/runtime.tsx +2 -2
- package/llms-full.txt +303 -142
- package/llms.txt +18 -18
- package/package.json +5 -2
- package/src/components/artifact.stories.tsx +138 -0
- package/src/components/artifact.tsx +581 -0
- package/src/components/attachments.stories.tsx +7 -8
- package/src/components/attachments.tsx +2 -2
- package/src/components/card.tsx +110 -0
- package/src/components/chain-of-thought.stories.tsx +7 -8
- package/src/components/chat-container.stories.tsx +7 -8
- package/src/components/chat-container.tsx +4 -0
- package/src/components/checkpoint.stories.tsx +7 -8
- package/src/components/code-block.stories.tsx +8 -9
- package/src/components/component-meta.json +3411 -0
- package/src/components/confirm-card.stories.tsx +74 -0
- package/src/components/confirm-card.tsx +299 -0
- package/src/components/context.stories.tsx +7 -8
- package/src/components/conversation-item.stories.tsx +7 -8
- package/src/components/conversation-item.tsx +2 -2
- package/src/components/conversation-list.stories.tsx +7 -8
- package/src/components/conversation-list.tsx +1 -1
- package/src/components/embed.tsx +196 -0
- package/src/components/empty.stories.tsx +8 -9
- package/src/components/feedback-bar.stories.tsx +7 -8
- package/src/components/file-tree.stories.tsx +73 -0
- package/src/components/file-tree.tsx +383 -0
- package/src/components/file-upload.stories.tsx +7 -8
- package/src/components/form-widgets.tsx +461 -0
- package/src/components/form.tsx +796 -0
- package/src/components/image.stories.tsx +7 -8
- package/src/components/link-card.tsx +194 -0
- package/src/components/loader.stories.tsx +7 -8
- package/src/components/markdown.stories.tsx +7 -8
- package/src/components/message-narrow.stories.tsx +12 -13
- package/src/components/message-skills.stories.tsx +16 -17
- package/src/components/message.stories.tsx +17 -18
- package/src/components/model-switcher.stories.tsx +7 -8
- package/src/components/prompt-input.stories.tsx +8 -9
- package/src/components/prompt-suggestion.stories.tsx +7 -8
- package/src/components/prompt-suggestion.tsx +3 -3
- package/src/components/reasoning.stories.tsx +7 -8
- package/src/components/scroll-button.stories.tsx +7 -8
- package/src/components/slash-command.stories.tsx +8 -9
- package/src/components/slash-command.tsx +2 -2
- package/src/components/source.stories.tsx +7 -8
- package/src/components/source.tsx +1 -1
- package/src/components/task-list-card.stories.tsx +78 -0
- package/src/components/task-list-card.tsx +388 -0
- package/src/components/text-shimmer.stories.tsx +7 -8
- package/src/components/thinking-bar.stories.tsx +7 -8
- package/src/components/tool.stories.tsx +7 -8
- package/src/components/tool.tsx +2 -2
- package/src/components/voice-input.stories.tsx +7 -8
- package/src/elements/artifact.stories.tsx +291 -0
- package/src/elements/artifact.tsx +72 -0
- package/src/elements/{kitn-attachments.stories.tsx → attachments.stories.tsx} +11 -20
- package/src/elements/attachments.tsx +4 -4
- package/src/elements/card.stories.tsx +118 -0
- package/src/elements/card.tsx +40 -0
- package/src/elements/catalog.stories.tsx +491 -0
- package/src/elements/{kitn-chain-of-thought.stories.tsx → chain-of-thought.stories.tsx} +13 -22
- package/src/elements/chain-of-thought.tsx +3 -3
- package/src/elements/{kitn-chat-scope-picker.stories.tsx → chat-scope-picker.stories.tsx} +10 -19
- package/src/elements/chat-scope-picker.tsx +4 -4
- package/src/elements/{kitn-chat-workspace.stories.tsx → chat-workspace.stories.tsx} +15 -23
- package/src/elements/chat-workspace.tsx +2 -2
- package/src/elements/{kitn-chat.stories.tsx → chat.stories.tsx} +12 -20
- package/src/elements/chat.tsx +2 -2
- package/src/elements/{kitn-checkpoint.stories.tsx → checkpoint.stories.tsx} +11 -20
- package/src/elements/checkpoint.tsx +4 -4
- package/src/elements/{kitn-code-block.stories.tsx → code-block.stories.tsx} +10 -19
- package/src/elements/code-block.tsx +3 -3
- package/src/elements/compiled.css +1 -1
- package/src/elements/composed-shell.stories.tsx +316 -0
- package/src/elements/confirm-card.stories.tsx +186 -0
- package/src/elements/confirm-card.tsx +45 -0
- package/src/elements/{kitn-context-meter.stories.tsx → context-meter.stories.tsx} +10 -19
- package/src/elements/context-meter.tsx +3 -3
- package/src/elements/{kitn-conversation-list.stories.tsx → conversation-list.stories.tsx} +12 -20
- package/src/elements/conversation-list.tsx +2 -2
- package/src/elements/css.ts +1 -1
- package/src/elements/define.tsx +10 -10
- package/src/elements/element-meta.json +1379 -733
- package/src/elements/element-types.d.ts +251 -125
- package/src/elements/embed.stories.tsx +197 -0
- package/src/elements/embed.tsx +35 -0
- package/src/elements/{kitn-empty.stories.tsx → empty.stories.tsx} +12 -21
- package/src/elements/empty.tsx +3 -3
- package/src/elements/{kitn-feedback-bar.stories.tsx → feedback-bar.stories.tsx} +11 -20
- package/src/elements/feedback-bar.tsx +4 -4
- package/src/elements/file-tree.stories.tsx +133 -0
- package/src/elements/file-tree.tsx +52 -0
- package/src/elements/{kitn-file-upload.stories.tsx → file-upload.stories.tsx} +12 -21
- package/src/elements/file-upload.tsx +4 -4
- package/src/elements/form.stories.tsx +204 -0
- package/src/elements/form.tsx +37 -0
- package/src/elements/{kitn-image.stories.tsx → image.stories.tsx} +10 -19
- package/src/elements/image.tsx +3 -3
- package/src/elements/link-card.stories.tsx +193 -0
- package/src/elements/link-card.tsx +34 -0
- package/src/elements/{kitn-loader.stories.tsx → loader.stories.tsx} +11 -20
- package/src/elements/loader.tsx +3 -3
- package/src/elements/{kitn-markdown.stories.tsx → markdown.stories.tsx} +10 -19
- package/src/elements/markdown.tsx +3 -3
- package/src/elements/{kitn-message-skills.stories.tsx → message-skills.stories.tsx} +10 -19
- package/src/elements/message-skills.tsx +3 -3
- package/src/elements/{kitn-message.stories.tsx → message.stories.tsx} +12 -21
- package/src/elements/message.tsx +5 -5
- package/src/elements/{kitn-model-switcher.stories.tsx → model-switcher.stories.tsx} +10 -19
- package/src/elements/model-switcher.tsx +5 -5
- package/src/elements/{kitn-prompt-input.stories.tsx → prompt-input.stories.tsx} +14 -22
- package/src/elements/prompt-input.tsx +3 -3
- package/src/elements/{kitn-prompt-suggestions.stories.tsx → prompt-suggestions.stories.tsx} +13 -22
- package/src/elements/prompt-suggestions.tsx +4 -4
- package/src/elements/{kitn-reasoning.stories.tsx → reasoning.stories.tsx} +10 -19
- package/src/elements/reasoning.tsx +4 -4
- package/src/elements/register.ts +11 -1
- package/src/elements/resizable.stories.tsx +200 -0
- package/src/elements/resizable.tsx +264 -0
- package/src/elements/{kitn-response-stream.stories.tsx → response-stream.stories.tsx} +10 -19
- package/src/elements/response-stream.tsx +4 -4
- package/src/elements/{kitn-source-list.stories.tsx → source-list.stories.tsx} +11 -20
- package/src/elements/{kitn-source.stories.tsx → source.stories.tsx} +12 -21
- package/src/elements/source.tsx +5 -5
- package/src/elements/styles.css +140 -1
- package/src/elements/task-list-card.stories.tsx +194 -0
- package/src/elements/task-list-card.tsx +40 -0
- package/src/elements/{kitn-text-shimmer.stories.tsx → text-shimmer.stories.tsx} +10 -19
- package/src/elements/text-shimmer.tsx +3 -3
- package/src/elements/{kitn-thinking-bar.stories.tsx → thinking-bar.stories.tsx} +11 -20
- package/src/elements/thinking-bar.tsx +5 -5
- package/src/elements/{kitn-tool.stories.tsx → tool.stories.tsx} +10 -19
- package/src/elements/tool.tsx +3 -3
- package/src/elements/{kitn-voice-input.stories.tsx → voice-input.stories.tsx} +10 -19
- package/src/elements/voice-input.tsx +4 -4
- package/src/index.ts +94 -2
- package/src/primitives/card-contract.ts +60 -0
- package/src/primitives/card-host.tsx +35 -0
- package/src/primitives/card-routing.ts +79 -0
- package/src/primitives/card-schemas/card-envelope.schema.json +14 -0
- package/src/primitives/card-schemas/card-event.schema.json +12 -0
- package/src/primitives/card-schemas/confirm.schema.json +65 -0
- package/src/primitives/card-schemas/embed.schema.json +65 -0
- package/src/primitives/card-schemas/form.result.schema.json +7 -0
- package/src/primitives/card-schemas/form.schema.json +33 -0
- package/src/primitives/card-schemas/link.schema.json +56 -0
- package/src/primitives/card-schemas/task-list.result.schema.json +16 -0
- package/src/primitives/card-schemas/task-list.schema.json +78 -0
- package/src/primitives/card-validate.ts +95 -0
- package/src/primitives/embed-providers.ts +254 -0
- package/src/primitives/highlighter.ts +4 -0
- package/src/primitives/link-preview.ts +87 -0
- package/src/primitives/pdf-preview.ts +121 -0
- package/src/stories/chat-panel-layout.stories.tsx +2 -1
- package/src/stories/chat-scene.tsx +22 -21
- package/src/stories/checkpoint-restore.stories.tsx +10 -10
- package/src/stories/conversation-with-reasoning.stories.tsx +4 -4
- package/src/stories/conversation-with-sources.stories.tsx +7 -7
- package/src/stories/docs/Accessibility.mdx +2 -2
- package/src/stories/docs/ForAIAgents.mdx +3 -3
- package/src/stories/docs/GettingStarted.mdx +2 -2
- package/src/stories/docs/Installation.mdx +2 -2
- package/src/stories/docs/Integrations.mdx +29 -29
- package/src/stories/docs/Introduction.mdx +3 -3
- package/src/stories/docs/Theming.mdx +2 -2
- package/src/stories/docs/element-controls.ts +32 -0
- package/src/stories/docs/theme-editor/theme-editor.tsx +1 -0
- package/src/stories/examples/ChoosingComponents.mdx +94 -0
- package/src/stories/examples/sample-data.ts +79 -0
- package/src/stories/message-actions.stories.tsx +13 -13
- package/src/stories/pattern-centered-conversation.stories.tsx +3 -3
- package/src/stories/pattern-docked-widget.stories.tsx +1 -1
- package/src/stories/pattern-empty-state.stories.tsx +3 -3
- package/src/stories/prompt-input-variants.stories.tsx +13 -13
- package/src/stories/streaming-response.stories.tsx +3 -3
- package/src/stories/typography.stories.tsx +4 -4
- package/src/ui/avatar.stories.tsx +7 -8
- package/src/ui/badge.stories.tsx +7 -8
- package/src/ui/button.stories.tsx +8 -9
- package/src/ui/button.tsx +1 -0
- package/src/ui/collapsible.stories.tsx +6 -7
- package/src/ui/dropdown.stories.tsx +6 -7
- package/src/ui/hover-card.stories.tsx +6 -7
- package/src/ui/resizable.stories.tsx +74 -9
- package/src/ui/resizable.tsx +351 -71
- package/src/ui/scroll-area.stories.tsx +6 -7
- package/src/ui/scroll-area.tsx +3 -1
- package/src/ui/separator.stories.tsx +7 -8
- package/src/ui/skeleton.stories.tsx +7 -8
- package/src/ui/textarea.stories.tsx +6 -7
- package/src/ui/tooltip.stories.tsx +8 -9
- package/theme.css +65 -65
- package/src/stories/docs/element-spec.tsx +0 -86
|
@@ -92,7 +92,7 @@ function renderHighlighted(text: string, highlight: string) {
|
|
|
92
92
|
const index = textLower.indexOf(highlightLower);
|
|
93
93
|
|
|
94
94
|
if (index === -1) {
|
|
95
|
-
return <span class="text-
|
|
95
|
+
return <span class="text-foreground/70 whitespace-pre-wrap">{text}</span>;
|
|
96
96
|
}
|
|
97
97
|
|
|
98
98
|
const before = text.substring(0, index);
|
|
@@ -102,11 +102,11 @@ function renderHighlighted(text: string, highlight: string) {
|
|
|
102
102
|
return (
|
|
103
103
|
<>
|
|
104
104
|
<Show when={before}>
|
|
105
|
-
<span class="text-
|
|
105
|
+
<span class="text-foreground/70 whitespace-pre-wrap">{before}</span>
|
|
106
106
|
</Show>
|
|
107
107
|
<span class="text-primary font-medium whitespace-pre-wrap">{matched}</span>
|
|
108
108
|
<Show when={after}>
|
|
109
|
-
<span class="text-
|
|
109
|
+
<span class="text-foreground/70 whitespace-pre-wrap">{after}</span>
|
|
110
110
|
</Show>
|
|
111
111
|
</>
|
|
112
112
|
);
|
|
@@ -2,6 +2,7 @@ import type { Meta, StoryObj } from 'storybook-solidjs-vite';
|
|
|
2
2
|
import { fn } from 'storybook/test';
|
|
3
3
|
import { createSignal } from 'solid-js';
|
|
4
4
|
import { Reasoning, ReasoningTrigger, ReasoningContent } from './reasoning';
|
|
5
|
+
import { componentDescription } from '../stories/docs/element-controls';
|
|
5
6
|
|
|
6
7
|
const meta = {
|
|
7
8
|
title: 'Components/Reasoning',
|
|
@@ -10,14 +11,12 @@ const meta = {
|
|
|
10
11
|
parameters: {
|
|
11
12
|
layout: 'padded',
|
|
12
13
|
docs: {
|
|
13
|
-
description:
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
].join('\n\n'),
|
|
20
|
-
},
|
|
14
|
+
description: componentDescription([
|
|
15
|
+
'A collapsible disclosure for a model\'s thinking/reasoning. Compose `Reasoning` (root) with `ReasoningTrigger` (the toggle) and `ReasoningContent` (the body, with optional markdown).',
|
|
16
|
+
'**When to use:** to surface an assistant\'s chain-of-thought or scratch reasoning that should be available but collapsed by default. It can auto-open while streaming and auto-close when streaming ends.',
|
|
17
|
+
'**How to use:** wrap a trigger and content in `Reasoning`. Leave it uncontrolled, or drive it with `open` + `onOpenChange`. Set `isStreaming` to auto-open during generation. Pass `markdown` on `ReasoningContent` to render a markdown string.',
|
|
18
|
+
'**Placement:** inside or above an assistant message, before the final answer.',
|
|
19
|
+
]),
|
|
21
20
|
controls: { exclude: ['use:eventListener'] },
|
|
22
21
|
},
|
|
23
22
|
},
|
|
@@ -2,6 +2,7 @@ import type { Meta, StoryObj } from 'storybook-solidjs-vite';
|
|
|
2
2
|
import { For } from 'solid-js';
|
|
3
3
|
import { ScrollButton } from './scroll-button';
|
|
4
4
|
import { ChatContainerRoot, ChatContainerContent } from './chat-container';
|
|
5
|
+
import { componentDescription } from '../stories/docs/element-controls';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* `ScrollButton` reads scroll state from the surrounding `ChatContainerRoot`
|
|
@@ -37,14 +38,12 @@ const meta = {
|
|
|
37
38
|
parameters: {
|
|
38
39
|
layout: 'padded',
|
|
39
40
|
docs: {
|
|
40
|
-
description:
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
].join('\n\n'),
|
|
47
|
-
},
|
|
41
|
+
description: componentDescription([
|
|
42
|
+
'A floating "scroll to bottom" button wired to the enclosing `ChatContainerRoot`. It calls `scrollToBottom()` on click and animates in/out based on `isAtBottom`.',
|
|
43
|
+
'**When to use:** in a scrollable message log, to let the user jump back to the latest message after scrolling up. It hides itself automatically while pinned to the bottom.',
|
|
44
|
+
'**How to use:** render it inside a `ChatContainerRoot` (it consumes that context). Position it with absolute layout and optionally restyle via `variant`, `size`, and `class`.',
|
|
45
|
+
'**Placement:** overlaid near the bottom-center of the chat message area.',
|
|
46
|
+
]),
|
|
48
47
|
controls: { exclude: ['use:eventListener'] },
|
|
49
48
|
},
|
|
50
49
|
},
|
|
@@ -4,6 +4,7 @@ import { createSignal } from "solid-js";
|
|
|
4
4
|
import { SlashCommand, type SlashCommandItem } from "./slash-command";
|
|
5
5
|
import { PromptInput, PromptInputTextarea } from "./prompt-input";
|
|
6
6
|
import { ChatConfig } from "../primitives/chat-config";
|
|
7
|
+
import { componentDescription } from '../stories/docs/element-controls';
|
|
7
8
|
|
|
8
9
|
const skillCommands: SlashCommandItem[] = [
|
|
9
10
|
{ id: "caveman", label: "Caveman", description: "Ultra-compressed terse responses", category: "Skills" },
|
|
@@ -43,7 +44,7 @@ function SlashDemo(props: { commands: SlashCommandItem[]; activeIds?: string[];
|
|
|
43
44
|
/>
|
|
44
45
|
</PromptInput>
|
|
45
46
|
</div>
|
|
46
|
-
<p class="text-xs text-muted-foreground
|
|
47
|
+
<p class="text-xs text-muted-foreground mt-2 text-center">
|
|
47
48
|
Type <code class="bg-muted px-1 rounded">/</code> to see commands. Arrow keys + Tab/Enter to select.
|
|
48
49
|
</p>
|
|
49
50
|
</div>
|
|
@@ -58,14 +59,12 @@ const meta = {
|
|
|
58
59
|
parameters: {
|
|
59
60
|
layout: "centered",
|
|
60
61
|
docs: {
|
|
61
|
-
description:
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
].join("\n\n"),
|
|
68
|
-
},
|
|
62
|
+
description: componentDescription([
|
|
63
|
+
"A keyboard-navigable command palette that pops above a `PromptInput` when the user types `/`. Filters and groups commands by category, with arrow-key navigation and Tab/Enter to select.",
|
|
64
|
+
"**When to use:** to offer slash commands or toggleable skills/modes directly from the prompt input as the user types `/name`.",
|
|
65
|
+
"**How to use:** render it as a child of `PromptInput` (it consumes that context). Pass `commands`, handle `onSelect`, and optionally pass `activeIds` to mark active toggles. Set `compact={false}` for two-line rows.",
|
|
66
|
+
"**Placement:** inside the relative-positioned `PromptInput`, absolutely anchored to the top of the textarea.",
|
|
67
|
+
]),
|
|
69
68
|
controls: { exclude: ["use:eventListener"] },
|
|
70
69
|
},
|
|
71
70
|
},
|
|
@@ -187,8 +187,8 @@ function SlashCommand(props: SlashCommandProps) {
|
|
|
187
187
|
"w-full flex items-center gap-2 px-3 text-left transition-colors",
|
|
188
188
|
isCompact ? "py-1" : "py-1.5",
|
|
189
189
|
selectedIndex() === idx
|
|
190
|
-
? "bg-muted
|
|
191
|
-
: "text-foreground
|
|
190
|
+
? "bg-muted text-foreground"
|
|
191
|
+
: "text-foreground hover:bg-muted",
|
|
192
192
|
)}
|
|
193
193
|
onMouseEnter={() => setSelectedIndex(idx)}
|
|
194
194
|
onClick={() => selectItem(item)}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from 'storybook-solidjs-vite';
|
|
2
2
|
import { Source, SourceTrigger, SourceContent, SourceList } from './source';
|
|
3
|
+
import { componentDescription } from '../stories/docs/element-controls';
|
|
3
4
|
|
|
4
5
|
const meta = {
|
|
5
6
|
title: 'Components/Source',
|
|
@@ -8,14 +9,12 @@ const meta = {
|
|
|
8
9
|
parameters: {
|
|
9
10
|
layout: 'padded',
|
|
10
11
|
docs: {
|
|
11
|
-
description:
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
].join('\n\n'),
|
|
18
|
-
},
|
|
12
|
+
description: componentDescription([
|
|
13
|
+
'An inline citation chip with a hover-card preview. Compose `Source` (root, holds the `href`) with `SourceTrigger` (the clickable pill) and `SourceContent` (the hover preview). `SourceList` lays out several side by side.',
|
|
14
|
+
'**When to use:** to cite a referenced web source inline in an assistant message — show a compact domain/number pill that previews the title and description on hover.',
|
|
15
|
+
'**How to use:** wrap `SourceTrigger` and `SourceContent` in `Source` with the target `href`. Use `label` for custom text or a citation number, `showFavicon` for the site icon, and pass `title`/`description` to `SourceContent`.',
|
|
16
|
+
'**Placement:** within message body text as citations, or grouped under a message in a `SourceList`.',
|
|
17
|
+
]),
|
|
19
18
|
controls: { exclude: ['use:eventListener'] },
|
|
20
19
|
},
|
|
21
20
|
},
|
|
@@ -61,7 +61,7 @@ function SourceTrigger(props: SourceTriggerProps) {
|
|
|
61
61
|
target="_blank"
|
|
62
62
|
rel="noopener noreferrer"
|
|
63
63
|
class={cn(
|
|
64
|
-
'bg-muted text-
|
|
64
|
+
'bg-muted text-foreground/80 hover:bg-muted-foreground/30 hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background inline-flex h-5 max-w-32 items-center gap-1 overflow-hidden rounded-full py-0 text-xs no-underline transition-colors duration-150',
|
|
65
65
|
props.showFavicon ? 'pr-2 pl-1' : 'px-2',
|
|
66
66
|
props.class
|
|
67
67
|
)}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from 'storybook-solidjs-vite';
|
|
2
|
+
import { createSignal } from 'solid-js';
|
|
3
|
+
import { TaskListCard, type TaskListCardData } from './task-list-card';
|
|
4
|
+
import { componentDescription } from '../stories/docs/element-controls';
|
|
5
|
+
import type { CardEvent, CardHost, CardContext } from '../primitives/card-contract';
|
|
6
|
+
|
|
7
|
+
const ctx: CardContext = { theme: { mode: 'light' }, locale: 'en' };
|
|
8
|
+
|
|
9
|
+
/** Renders the Solid <TaskListCard> with a capturing `host`, logging emitted events. */
|
|
10
|
+
function Demo(props: { def: TaskListCardData; heading?: string; cardId: string }) {
|
|
11
|
+
const [log, setLog] = createSignal<CardEvent[]>([]);
|
|
12
|
+
const host: CardHost = { context: () => ctx, emit: (e) => setLog((p) => [...p, e]) };
|
|
13
|
+
return (
|
|
14
|
+
<div style={{ 'max-width': '460px', display: 'flex', 'flex-direction': 'column', gap: '12px' }}>
|
|
15
|
+
<TaskListCard host={host} data={props.def} heading={props.heading} cardId={props.cardId} />
|
|
16
|
+
<pre
|
|
17
|
+
style={{
|
|
18
|
+
margin: 0,
|
|
19
|
+
'max-height': '180px',
|
|
20
|
+
overflow: 'auto',
|
|
21
|
+
background: 'var(--color-muted, #f4f4f5)',
|
|
22
|
+
'border-radius': '8px',
|
|
23
|
+
padding: '8px',
|
|
24
|
+
'font-size': '12px',
|
|
25
|
+
}}
|
|
26
|
+
>
|
|
27
|
+
{log().length === 0 ? '// emitted CardEvents appear here' : JSON.stringify(log(), null, 2)}
|
|
28
|
+
</pre>
|
|
29
|
+
</div>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const PLAN: TaskListCardData = {
|
|
34
|
+
selectAll: true,
|
|
35
|
+
confirmLabel: 'Run selected',
|
|
36
|
+
tasks: [
|
|
37
|
+
{ id: 'lint', label: 'Run linter', checked: true },
|
|
38
|
+
{ id: 'test', label: 'Run unit tests', checked: true },
|
|
39
|
+
{ id: 'build', label: 'Build production bundle' },
|
|
40
|
+
{ id: 'deploy', label: 'Deploy to staging', description: 'Reversible; staging only' },
|
|
41
|
+
],
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const BOUNDED: TaskListCardData = {
|
|
45
|
+
confirmLabel: 'Request review',
|
|
46
|
+
min: 1,
|
|
47
|
+
max: 2,
|
|
48
|
+
tasks: [
|
|
49
|
+
{ id: 'ana', label: 'Ana' },
|
|
50
|
+
{ id: 'ben', label: 'Ben' },
|
|
51
|
+
{ id: 'cat', label: 'Cat' },
|
|
52
|
+
],
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const meta = {
|
|
56
|
+
title: 'Components/TaskListCard',
|
|
57
|
+
component: TaskListCard,
|
|
58
|
+
tags: ['autodocs'],
|
|
59
|
+
parameters: {
|
|
60
|
+
layout: 'padded',
|
|
61
|
+
docs: {
|
|
62
|
+
description: componentDescription([
|
|
63
|
+
'The SolidJS layer behind `<kc-task-list>`. Pass a `host` (a `CardHost`) to receive the emitted `CardEvent`s directly (the native-host path), or wrap in a `CardProvider`. Toggling rows is local; only confirm emits `submit-data` with `{ selected }` in input order.',
|
|
64
|
+
]),
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
} satisfies Meta<typeof TaskListCard>;
|
|
68
|
+
|
|
69
|
+
export default meta;
|
|
70
|
+
type Story = StoryObj<typeof TaskListCard>;
|
|
71
|
+
|
|
72
|
+
export const SelectAPlan: Story = {
|
|
73
|
+
render: () => <Demo def={PLAN} heading="Approve the plan steps" cardId="card-plan" />,
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export const Bounded: Story = {
|
|
77
|
+
render: () => <Demo def={BOUNDED} heading="Pick up to 2 reviewers" cardId="card-bounded" />,
|
|
78
|
+
};
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type JSX,
|
|
3
|
+
For,
|
|
4
|
+
Show,
|
|
5
|
+
splitProps,
|
|
6
|
+
mergeProps,
|
|
7
|
+
createSignal,
|
|
8
|
+
createMemo,
|
|
9
|
+
createEffect,
|
|
10
|
+
on,
|
|
11
|
+
ErrorBoundary,
|
|
12
|
+
createUniqueId,
|
|
13
|
+
} from 'solid-js';
|
|
14
|
+
import { cn } from '../utils/cn';
|
|
15
|
+
import { Button } from '../ui/button';
|
|
16
|
+
import { Card } from './card';
|
|
17
|
+
import type { CardEnvelope, CardEvent, CardHost } from '../primitives/card-contract';
|
|
18
|
+
import { emitCardEvent } from '../primitives/card-routing';
|
|
19
|
+
import { useCardHost } from '../primitives/card-host';
|
|
20
|
+
|
|
21
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
22
|
+
// Types (task-list.schema.json) — see src/primitives/card-schemas/task-list.schema.json
|
|
23
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
export interface TaskListTask {
|
|
26
|
+
id: string;
|
|
27
|
+
label: string;
|
|
28
|
+
description?: string;
|
|
29
|
+
checked?: boolean;
|
|
30
|
+
disabled?: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface TaskListCardData {
|
|
34
|
+
mode?: 'select'; // future: 'select' | 'progress'
|
|
35
|
+
heading?: string;
|
|
36
|
+
tasks: TaskListTask[]; // >=1
|
|
37
|
+
selectAll?: boolean;
|
|
38
|
+
confirmLabel?: string;
|
|
39
|
+
allowEmpty?: boolean;
|
|
40
|
+
min?: number;
|
|
41
|
+
max?: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface TaskListCardResult {
|
|
45
|
+
selected: string[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type TaskListCardEnvelope = CardEnvelope<'task-list', TaskListCardData>;
|
|
49
|
+
|
|
50
|
+
export const TASK_LIST_CARD_TYPE = 'task-list' as const;
|
|
51
|
+
|
|
52
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
53
|
+
// Pure helpers (unit-tested in isolation).
|
|
54
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
/** De-dupe tasks by id (first wins). Returns the usable list + an optional error. */
|
|
57
|
+
export function normalizeTasks(tasks: unknown): { tasks: TaskListTask[]; error?: string } {
|
|
58
|
+
if (!Array.isArray(tasks) || tasks.length === 0) {
|
|
59
|
+
return { tasks: [], error: "This card couldn't be displayed." };
|
|
60
|
+
}
|
|
61
|
+
const seen = new Set<string>();
|
|
62
|
+
const out: TaskListTask[] = [];
|
|
63
|
+
for (const t of tasks) {
|
|
64
|
+
if (!t || typeof t !== 'object') continue;
|
|
65
|
+
const task = t as Partial<TaskListTask>;
|
|
66
|
+
if (typeof task.id !== 'string' || task.id.length === 0) continue;
|
|
67
|
+
if (typeof task.label !== 'string' || task.label.length === 0) continue;
|
|
68
|
+
if (seen.has(task.id)) {
|
|
69
|
+
// eslint-disable-next-line no-console
|
|
70
|
+
console.warn(`[kc-task-list] duplicate task id "${task.id}" ignored`);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
seen.add(task.id);
|
|
74
|
+
out.push({
|
|
75
|
+
id: task.id,
|
|
76
|
+
label: task.label,
|
|
77
|
+
description: typeof task.description === 'string' ? task.description : undefined,
|
|
78
|
+
checked: task.checked === true,
|
|
79
|
+
disabled: task.disabled === true,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
if (out.length === 0) return { tasks: [], error: "This card couldn't be displayed." };
|
|
83
|
+
return { tasks: out };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The ids of the initially-checked tasks (in input order). */
|
|
87
|
+
export function initialSelected(tasks: TaskListTask[]): string[] {
|
|
88
|
+
return tasks.filter((t) => t.checked).map((t) => t.id);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The selected ids in input order (selection set ∩ tasks, preserving task order). */
|
|
92
|
+
export function selectedInOrder(tasks: TaskListTask[], selected: Set<string>): string[] {
|
|
93
|
+
return tasks.filter((t) => selected.has(t.id)).map((t) => t.id);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The toggleable (non-disabled) task ids. */
|
|
97
|
+
export function toggleableIds(tasks: TaskListTask[]): string[] {
|
|
98
|
+
return tasks.filter((t) => !t.disabled).map((t) => t.id);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export type SelectAllState = 'checked' | 'unchecked' | 'indeterminate';
|
|
102
|
+
|
|
103
|
+
/** Select-all tri-state over the toggleable rows. */
|
|
104
|
+
export function selectAllState(tasks: TaskListTask[], selected: Set<string>): SelectAllState {
|
|
105
|
+
const ids = toggleableIds(tasks);
|
|
106
|
+
if (ids.length === 0) return 'unchecked';
|
|
107
|
+
const n = ids.filter((id) => selected.has(id)).length;
|
|
108
|
+
if (n === 0) return 'unchecked';
|
|
109
|
+
if (n === ids.length) return 'checked';
|
|
110
|
+
return 'indeterminate';
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Whether select-all should be shown: requested AND not blocked by `max` (since
|
|
114
|
+
* "all" would violate max). */
|
|
115
|
+
export function showSelectAll(data: TaskListCardData, tasks: TaskListTask[]): boolean {
|
|
116
|
+
if (data.selectAll !== true) return false;
|
|
117
|
+
const count = toggleableIds(tasks).length;
|
|
118
|
+
if (data.max !== undefined && count > data.max) return false;
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Whether confirm is enabled for the current selection count. */
|
|
123
|
+
export function canConfirm(data: TaskListCardData, count: number): boolean {
|
|
124
|
+
const min = data.min ?? (data.allowEmpty ? 0 : 1);
|
|
125
|
+
if (count < min) return false;
|
|
126
|
+
if (data.max !== undefined && count > data.max) return false;
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Whether an unchecked row is blocked because `max` is reached. */
|
|
131
|
+
export function isMaxReached(data: TaskListCardData, count: number): boolean {
|
|
132
|
+
return data.max !== undefined && count >= data.max;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The disabled-reason text for confirm (for aria-describedby), or undefined. */
|
|
136
|
+
export function confirmReason(data: TaskListCardData, count: number): string | undefined {
|
|
137
|
+
if (canConfirm(data, count)) return undefined;
|
|
138
|
+
const min = data.min ?? (data.allowEmpty ? 0 : 1);
|
|
139
|
+
if (count < min) return `Select at least ${min}.`;
|
|
140
|
+
if (data.max !== undefined && count > data.max) return `Select at most ${data.max}.`;
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
145
|
+
// The <TaskListCard> component.
|
|
146
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
export interface TaskListCardProps {
|
|
149
|
+
/** The task-list definition (CardEnvelope.data). */
|
|
150
|
+
data?: TaskListCardData;
|
|
151
|
+
/** The card id used to correlate every emitted CardEvent. */
|
|
152
|
+
cardId?: string;
|
|
153
|
+
/** The envelope title rendered in the card chrome. */
|
|
154
|
+
heading?: string;
|
|
155
|
+
/** Optional explicit CardHost (otherwise read from a CardProvider, otherwise the
|
|
156
|
+
* bubbling `kc-card` CustomEvent off `hostElement`). */
|
|
157
|
+
host?: CardHost;
|
|
158
|
+
/** The custom-element host node, for the bubbling `kc-card` fallback emit. */
|
|
159
|
+
hostElement?: HTMLElement;
|
|
160
|
+
class?: string;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const DEFAULT_DATA: TaskListCardData = { tasks: [] };
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* `TaskListCard` — a selectable task/plan list (checkbox rows + optional select-all
|
|
167
|
+
* + a confirm button) inside `Card` chrome. Row toggling and select-all are local
|
|
168
|
+
* UI state; only the final confirm emits the Card contract's `submit-data` verb
|
|
169
|
+
* (`{ kind:'submit-data', cardId, data:{ selected } }`) with the checked ids in
|
|
170
|
+
* input order. Emits `ready` on mount and `error` for an unusable definition.
|
|
171
|
+
*/
|
|
172
|
+
export function TaskListCard(props: TaskListCardProps): JSX.Element {
|
|
173
|
+
const merged = mergeProps({ cardId: 'kc-task-list' }, props);
|
|
174
|
+
const [local] = splitProps(merged, ['data', 'cardId', 'heading', 'host', 'hostElement', 'class']);
|
|
175
|
+
|
|
176
|
+
const ctxHost = useCardHost();
|
|
177
|
+
const uid = createUniqueId();
|
|
178
|
+
|
|
179
|
+
const emit = (event: CardEvent): void => {
|
|
180
|
+
const h = local.host ?? ctxHost;
|
|
181
|
+
if (h) h.emit(event);
|
|
182
|
+
else if (local.hostElement) emitCardEvent(local.hostElement, event);
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const normalized = createMemo(() => normalizeTasks(local.data?.tasks));
|
|
186
|
+
const valid = createMemo(() => normalized().error === undefined);
|
|
187
|
+
const errorMessage = createMemo(() => normalized().error ?? '');
|
|
188
|
+
const tasks = createMemo(() => normalized().tasks);
|
|
189
|
+
const data = createMemo<TaskListCardData>(() => local.data ?? DEFAULT_DATA);
|
|
190
|
+
|
|
191
|
+
const [selected, setSelected] = createSignal<Set<string>>(new Set());
|
|
192
|
+
const [submitted, setSubmitted] = createSignal(false);
|
|
193
|
+
|
|
194
|
+
// Seed selection from the tasks' initial checked state when a NEW definition arrives.
|
|
195
|
+
createEffect(
|
|
196
|
+
on(
|
|
197
|
+
() => local.data,
|
|
198
|
+
() => {
|
|
199
|
+
setSelected(new Set(initialSelected(tasks())));
|
|
200
|
+
setSubmitted(false);
|
|
201
|
+
},
|
|
202
|
+
),
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
// ready / error lifecycle emits.
|
|
206
|
+
createEffect(
|
|
207
|
+
on(valid, (ok) => {
|
|
208
|
+
if (ok) emit({ kind: 'ready', cardId: local.cardId });
|
|
209
|
+
else emit({ kind: 'error', cardId: local.cardId, message: errorMessage() });
|
|
210
|
+
}),
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
const count = createMemo(() => selected().size);
|
|
214
|
+
const confirmLabel = () => data().confirmLabel ?? 'Confirm';
|
|
215
|
+
const masterState = createMemo(() => selectAllState(tasks(), selected()));
|
|
216
|
+
const showMaster = createMemo(() => showSelectAll(data(), tasks()));
|
|
217
|
+
const reason = createMemo(() => confirmReason(data(), count()));
|
|
218
|
+
const confirmEnabled = createMemo(() => canConfirm(data(), count()) && !submitted());
|
|
219
|
+
|
|
220
|
+
const toggle = (id: string, on: boolean): void => {
|
|
221
|
+
if (submitted()) return;
|
|
222
|
+
const next = new Set(selected());
|
|
223
|
+
if (on) {
|
|
224
|
+
// Respect max: block adding past max.
|
|
225
|
+
if (isMaxReached(data(), next.size) && !next.has(id)) return;
|
|
226
|
+
next.add(id);
|
|
227
|
+
} else {
|
|
228
|
+
next.delete(id);
|
|
229
|
+
}
|
|
230
|
+
setSelected(next);
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const toggleAll = (on: boolean): void => {
|
|
234
|
+
if (submitted()) return;
|
|
235
|
+
const next = new Set(selected());
|
|
236
|
+
for (const id of toggleableIds(tasks())) {
|
|
237
|
+
if (on) next.add(id);
|
|
238
|
+
else next.delete(id);
|
|
239
|
+
}
|
|
240
|
+
setSelected(next);
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
const onConfirm = (): void => {
|
|
244
|
+
if (!confirmEnabled()) return;
|
|
245
|
+
const result: TaskListCardResult = { selected: selectedInOrder(tasks(), selected()) };
|
|
246
|
+
emit({ kind: 'submit-data', cardId: local.cardId, data: result });
|
|
247
|
+
setSubmitted(true);
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
const reasonId = `kc-tl-reason-${uid}`;
|
|
251
|
+
const countId = `kc-tl-count-${uid}`;
|
|
252
|
+
|
|
253
|
+
return (
|
|
254
|
+
<Show when={valid()} fallback={<Card heading={local.heading} errorMessage={errorMessage()} />}>
|
|
255
|
+
<ErrorBoundary
|
|
256
|
+
fallback={() => {
|
|
257
|
+
emit({ kind: 'error', cardId: local.cardId, message: 'The card failed to render.' });
|
|
258
|
+
return <Card heading={local.heading} errorMessage="The card failed to render." />;
|
|
259
|
+
}}
|
|
260
|
+
>
|
|
261
|
+
<Card
|
|
262
|
+
heading={local.heading ?? local.data?.heading}
|
|
263
|
+
actions={
|
|
264
|
+
<div class="flex w-full flex-wrap items-center justify-between gap-2">
|
|
265
|
+
<span id={countId} aria-live="polite" class="text-xs text-muted-foreground">
|
|
266
|
+
{count()} selected
|
|
267
|
+
</span>
|
|
268
|
+
<div class="ml-auto flex items-center gap-2">
|
|
269
|
+
<Show when={reason()}>
|
|
270
|
+
<span id={reasonId} class="sr-only">
|
|
271
|
+
{reason()}
|
|
272
|
+
</span>
|
|
273
|
+
</Show>
|
|
274
|
+
<Button
|
|
275
|
+
type="button"
|
|
276
|
+
disabled={!confirmEnabled()}
|
|
277
|
+
aria-describedby={reason() ? reasonId : undefined}
|
|
278
|
+
onClick={onConfirm}
|
|
279
|
+
>
|
|
280
|
+
{confirmLabel()}
|
|
281
|
+
</Button>
|
|
282
|
+
</div>
|
|
283
|
+
</div>
|
|
284
|
+
}
|
|
285
|
+
>
|
|
286
|
+
<div
|
|
287
|
+
role="group"
|
|
288
|
+
aria-label={local.heading ?? local.data?.heading ?? 'Tasks'}
|
|
289
|
+
class={cn('flex flex-col', local.class)}
|
|
290
|
+
onKeyDown={(e) => {
|
|
291
|
+
// Enter anywhere in the card (off a checkbox) confirms when enabled.
|
|
292
|
+
if (e.key !== 'Enter') return;
|
|
293
|
+
const target = e.target as HTMLElement;
|
|
294
|
+
if (target.tagName === 'INPUT') return;
|
|
295
|
+
if (confirmEnabled()) onConfirm();
|
|
296
|
+
}}
|
|
297
|
+
>
|
|
298
|
+
<div class="divide-y divide-border overflow-hidden rounded-lg border border-input">
|
|
299
|
+
<Show when={showMaster()}>
|
|
300
|
+
{(() => {
|
|
301
|
+
const indeterminate = () => masterState() === 'indeterminate';
|
|
302
|
+
return (
|
|
303
|
+
<label
|
|
304
|
+
class={cn(
|
|
305
|
+
'flex cursor-pointer items-center gap-3 px-3 py-2.5 text-sm font-medium transition-colors',
|
|
306
|
+
masterState() === 'checked'
|
|
307
|
+
? 'bg-accent text-accent-foreground'
|
|
308
|
+
: 'text-foreground hover:bg-muted/50',
|
|
309
|
+
)}
|
|
310
|
+
>
|
|
311
|
+
<input
|
|
312
|
+
type="checkbox"
|
|
313
|
+
class="kc-checkbox"
|
|
314
|
+
checked={masterState() === 'checked'}
|
|
315
|
+
aria-checked={indeterminate() ? 'mixed' : masterState() === 'checked'}
|
|
316
|
+
disabled={submitted()}
|
|
317
|
+
ref={(el) => {
|
|
318
|
+
createEffect(() => {
|
|
319
|
+
el.indeterminate = indeterminate();
|
|
320
|
+
});
|
|
321
|
+
}}
|
|
322
|
+
onChange={(e) => toggleAll(e.currentTarget.checked)}
|
|
323
|
+
/>
|
|
324
|
+
<span>Select all</span>
|
|
325
|
+
</label>
|
|
326
|
+
);
|
|
327
|
+
})()}
|
|
328
|
+
</Show>
|
|
329
|
+
|
|
330
|
+
<For each={tasks()}>
|
|
331
|
+
{(task) => {
|
|
332
|
+
const checked = () => selected().has(task.id);
|
|
333
|
+
const blocked = () =>
|
|
334
|
+
task.disabled ||
|
|
335
|
+
submitted() ||
|
|
336
|
+
(!checked() && isMaxReached(data(), count()));
|
|
337
|
+
const descId = `kc-tl-desc-${uid}-${task.id}`;
|
|
338
|
+
return (
|
|
339
|
+
<label
|
|
340
|
+
class={cn(
|
|
341
|
+
'flex items-start gap-3 px-3 py-2.5 text-sm transition-colors',
|
|
342
|
+
blocked()
|
|
343
|
+
? 'cursor-not-allowed opacity-60'
|
|
344
|
+
: 'cursor-pointer hover:bg-muted/50',
|
|
345
|
+
checked() && !blocked()
|
|
346
|
+
? 'bg-accent font-medium text-accent-foreground'
|
|
347
|
+
: 'text-foreground',
|
|
348
|
+
)}
|
|
349
|
+
data-task-id={task.id}
|
|
350
|
+
>
|
|
351
|
+
<input
|
|
352
|
+
type="checkbox"
|
|
353
|
+
class="kc-checkbox mt-0.5"
|
|
354
|
+
checked={checked()}
|
|
355
|
+
disabled={blocked()}
|
|
356
|
+
aria-disabled={blocked() ? 'true' : undefined}
|
|
357
|
+
aria-describedby={task.description ? descId : undefined}
|
|
358
|
+
onChange={(e) => toggle(task.id, e.currentTarget.checked)}
|
|
359
|
+
/>
|
|
360
|
+
<span class="flex flex-col gap-0.5">
|
|
361
|
+
<span>{task.label}</span>
|
|
362
|
+
<Show when={task.description}>
|
|
363
|
+
<span id={descId} class="text-xs font-normal text-muted-foreground">
|
|
364
|
+
{task.description}
|
|
365
|
+
</span>
|
|
366
|
+
</Show>
|
|
367
|
+
</span>
|
|
368
|
+
</label>
|
|
369
|
+
);
|
|
370
|
+
}}
|
|
371
|
+
</For>
|
|
372
|
+
</div>
|
|
373
|
+
|
|
374
|
+
<Show when={data().max !== undefined}>
|
|
375
|
+
<p class="pt-1 text-xs text-muted-foreground">Up to {data().max} selected.</p>
|
|
376
|
+
</Show>
|
|
377
|
+
|
|
378
|
+
<Show when={submitted()}>
|
|
379
|
+
<p role="status" class="pt-1 text-sm text-muted-foreground">
|
|
380
|
+
Submitted.
|
|
381
|
+
</p>
|
|
382
|
+
</Show>
|
|
383
|
+
</div>
|
|
384
|
+
</Card>
|
|
385
|
+
</ErrorBoundary>
|
|
386
|
+
</Show>
|
|
387
|
+
);
|
|
388
|
+
}
|