@inploi/plugin-chatbot 2.1.0 → 2.1.2

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 (56) hide show
  1. package/cdn/index.js +56 -0
  2. package/package.json +12 -1
  3. package/.env +0 -2
  4. package/.env.example +0 -2
  5. package/.env.test +0 -2
  6. package/.eslintrc.cjs +0 -10
  7. package/CHANGELOG.md +0 -91
  8. package/bunfig.toml +0 -2
  9. package/happydom.ts +0 -10
  10. package/index.html +0 -29
  11. package/playwright.config.ts +0 -82
  12. package/postcss.config.cjs +0 -7
  13. package/src/chatbot.api.ts +0 -46
  14. package/src/chatbot.constants.ts +0 -9
  15. package/src/chatbot.css +0 -93
  16. package/src/chatbot.dom.ts +0 -28
  17. package/src/chatbot.idb.ts +0 -17
  18. package/src/chatbot.state.ts +0 -114
  19. package/src/chatbot.ts +0 -59
  20. package/src/chatbot.utils.ts +0 -56
  21. package/src/index.cdn.ts +0 -12
  22. package/src/index.dev.ts +0 -31
  23. package/src/index.ts +0 -1
  24. package/src/interpreter/interpreter.test.ts +0 -69
  25. package/src/interpreter/interpreter.ts +0 -249
  26. package/src/mocks/browser.ts +0 -5
  27. package/src/mocks/example.flows.ts +0 -801
  28. package/src/mocks/handlers.ts +0 -57
  29. package/src/style/palette.test.ts +0 -20
  30. package/src/style/palette.ts +0 -69
  31. package/src/ui/chat-bubble.tsx +0 -51
  32. package/src/ui/chat-input/chat-input.boolean.tsx +0 -62
  33. package/src/ui/chat-input/chat-input.file.tsx +0 -213
  34. package/src/ui/chat-input/chat-input.multiple-choice.tsx +0 -117
  35. package/src/ui/chat-input/chat-input.text.tsx +0 -111
  36. package/src/ui/chat-input/chat-input.tsx +0 -81
  37. package/src/ui/chatbot-header.tsx +0 -95
  38. package/src/ui/chatbot.tsx +0 -94
  39. package/src/ui/input-error.tsx +0 -33
  40. package/src/ui/job-application-content.tsx +0 -144
  41. package/src/ui/job-application-messages.tsx +0 -64
  42. package/src/ui/loading-indicator.tsx +0 -37
  43. package/src/ui/send-button.tsx +0 -27
  44. package/src/ui/transition.tsx +0 -1
  45. package/src/ui/typing-indicator.tsx +0 -12
  46. package/src/ui/useChatService.ts +0 -67
  47. package/src/ui/useFocus.ts +0 -10
  48. package/src/vite-env.d.ts +0 -1
  49. package/tailwind.config.ts +0 -119
  50. package/tests/integration.spec.ts +0 -19
  51. package/tests/test.ts +0 -22
  52. package/tsconfig.json +0 -33
  53. package/tsconfig.node.json +0 -10
  54. package/types.d.ts +0 -2
  55. package/vite.config.ts +0 -18
  56. /package/{public → cdn}/mockServiceWorker.js +0 -0
@@ -1,57 +0,0 @@
1
- import { invariant } from '@inploi/core/common';
2
- import { HttpResponse, http } from 'msw';
3
- import { match } from 'ts-pattern';
4
-
5
- import { automatedTestFlow, exampleFlows } from './example.flows';
6
-
7
- // sandbox URL
8
- const BASE_URL = 'https://preview.api.inploi.com';
9
-
10
- export const flowHandler = {
11
- /** Returns a valid flow. */
12
- success: http.get(`${BASE_URL}/flow/job/:jobId`, ctx => {
13
- invariant(typeof ctx.params.jobId === 'string', 'Missing job id');
14
- const mockApplication = {
15
- job: {
16
- id: +ctx.params.jobId,
17
- title: 'Test job',
18
- },
19
- company: {
20
- name: 'Test company',
21
- },
22
- flow: {
23
- id: 1,
24
- nodes: match(ctx.params.jobId)
25
- .with('1', () => exampleFlows.fromAlex)
26
- .with('test', () => automatedTestFlow)
27
- .otherwise(() => exampleFlows.helloWorld),
28
- version: 1,
29
- },
30
- };
31
- return new HttpResponse(JSON.stringify(mockApplication));
32
- }),
33
-
34
- /** Returns an invalid payload with a 200 code.
35
- * E.g.: if the backend returns something incompatible with the frontend.
36
- */
37
- invalid_payload: http.get(`${BASE_URL}/flow/job/:jobId`, () => {
38
- return new HttpResponse(JSON.stringify({ foo: 'bar' }), { status: 200 });
39
- }),
40
-
41
- /** Returns a laravel-structured error */
42
- exception: http.get(`${BASE_URL}/flow/job/:jobId`, () => {
43
- return new HttpResponse(JSON.stringify({ exception: 'server_error', message: 'Something bad happened' }), {
44
- status: 400,
45
- });
46
- }),
47
- };
48
-
49
- export const browserHandlers = [
50
- flowHandler.success,
51
- http.post(`${BASE_URL}/flow/job/:jobId`, () => {
52
- return new HttpResponse(JSON.stringify({ message: 'Success' }));
53
- }),
54
- http.post(`${BASE_URL}/analytics/log`, () => {
55
- return new HttpResponse(JSON.stringify({ message: 'Success' }));
56
- }),
57
- ];
@@ -1,20 +0,0 @@
1
- import { expect, test } from 'bun:test';
2
-
3
- import { PaletteShade, generatePalette } from './palette';
4
-
5
- test('returns palette when given valid shade and hue', () => {
6
- const palette = generatePalette(50);
7
-
8
- for (const shade in palette) {
9
- expect(palette[shade as PaletteShade]).toMatch(/\d+\.*\d* \d+\.*\d*% \d+\.*\d*%/);
10
- }
11
- });
12
-
13
- test('returns different palettes with different hues', () => {
14
- const palette1 = generatePalette(25);
15
- const palette2 = generatePalette(26);
16
-
17
- for (const shade in palette1) {
18
- expect(palette1[shade as PaletteShade]).not.toEqual(palette2[shade as PaletteShade]);
19
- }
20
- });
@@ -1,69 +0,0 @@
1
- import { Mode, converter, differenceEuclidean, formatCss, toGamut } from 'culori';
2
- import { DiffFn } from 'culori/src/difference';
3
-
4
- declare module 'culori' {
5
- export function toGamut(from: Mode, to: Mode, distance: DiffFn, tolerance: number): (color: string) => string;
6
- }
7
-
8
- const hsl = converter('hsl');
9
- const oklch = converter('oklch');
10
-
11
- export type PaletteShade = '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' | '10' | '11' | '12';
12
- const colorLevels: Record<PaletteShade, true> = {
13
- '1': true,
14
- '2': true,
15
- '3': true,
16
- '4': true,
17
- '5': true,
18
- '6': true,
19
- '7': true,
20
- '8': true,
21
- '9': true,
22
- '10': true,
23
- '11': true,
24
- '12': true,
25
- };
26
-
27
- const COLOR_LEVELS = Object.keys(colorLevels) as PaletteShade[];
28
- const CHROMA_MULTIPLIER = 1.5;
29
-
30
- const colorPresets: Record<PaletteShade, { l: number; c: number }> = {
31
- '1': { l: 97.78, c: 0.0108 },
32
- '2': { l: 93.56, c: 0.0321 },
33
- '3': { l: 88.11, c: 0.0609 },
34
- '4': { l: 82.67, c: 0.0908 },
35
- '5': { l: 74.22, c: 0.1398 },
36
- '6': { l: 64.78, c: 0.1472 },
37
- '7': { l: 57.33, c: 0.1299 },
38
- '8': { l: 46.89, c: 0.1067 },
39
- '9': { l: 39.44, c: 0.0898 },
40
- '10': { l: 32, c: 0.0726 },
41
- '11': { l: 23.78, c: 0.054 },
42
- '12': { l: 15.56, c: 0.0353 },
43
- };
44
-
45
- const getPaletteColor = (hueAngle: number) => (i: PaletteShade) => {
46
- const { c, l } = colorPresets[i];
47
- const color = 'oklch(' + l + '% ' + c * CHROMA_MULTIPLIER + ' ' + hueAngle + ')';
48
- const oklchColor = oklch(toGamut('p3', 'oklch', differenceEuclidean('oklch'), 0)(color));
49
-
50
- return formatCss(hsl(oklchColor)) as unknown as string;
51
- };
52
-
53
- export const generatePalette = (hueAngle: number) =>
54
- Object.fromEntries(COLOR_LEVELS.map(level => [level, getPaletteColor(hueAngle)(level)] as const)) as Record<
55
- PaletteShade,
56
- string
57
- >;
58
-
59
- // gets the hue saturation and lightness from a `hsl()` string. Includes % signs.
60
- const extractHsl = (color: string) => {
61
- return color.replace('hsl(', '').replace(')', '');
62
- };
63
-
64
- export const formatCssVariables = (palette: Record<PaletteShade, string>) => {
65
- const cssVariables = Object.entries(palette)
66
- .map(([level, color]) => `--i-a-${level}: ${extractHsl(color)};`)
67
- .join('\n');
68
- return `#isdk {\n${cssVariables}\n}`;
69
- };
@@ -1,51 +0,0 @@
1
- import type { VariantProps } from 'class-variance-authority';
2
- import { cva } from 'class-variance-authority';
3
- import { Variants, m } from 'framer-motion';
4
- import type { ComponentProps } from 'react';
5
-
6
- const chatBubbleVariants = cva(
7
- 'max-w-[min(100%,24rem)] flex-shrink-1 min-w-[2rem] text-md py-2 px-3 rounded-[18px] min-h-[36px] break-words',
8
- {
9
- variants: {
10
- side: {
11
- left: 'bg-lowest text-neutral-12 shadow-surface-md outline outline-1 outline-accent-11/[.08] rounded-bl-md',
12
- right: 'ml-auto bg-accent-7 text-lowest rounded-br-md bubble-right',
13
- },
14
- transitionState: {
15
- entering: 'opacity-0 translate-y-8',
16
- entered: 'opacity-100 translate-y-0',
17
- exiting: 'opacity-0 scale-0',
18
- exited: '',
19
- },
20
- },
21
-
22
- defaultVariants: {
23
- side: 'left',
24
- },
25
- },
26
- );
27
-
28
- type ChatBubbleVariants = VariantProps<typeof chatBubbleVariants>;
29
-
30
- const motionVariants: Variants = {
31
- hidden: { y: '100%', scale: 0.75 },
32
- shown: { y: 0, scale: 1 },
33
- };
34
-
35
- type ChatBubbleProps = ComponentProps<'p'> & ChatBubbleVariants;
36
- export const ChatBubble = ({ children, className, transitionState, side, ...props }: ChatBubbleProps) => {
37
- return (
38
- <m.p
39
- variants={motionVariants}
40
- initial="hidden"
41
- animate="shown"
42
- transition={{ type: 'spring', damping: 25, stiffness: 500 }}
43
- data-transition={transitionState}
44
- style={{ transformOrigin: side === 'left' ? '0% 50%' : '100% 50%' }}
45
- class={chatBubbleVariants({ className, side, transitionState })}
46
- {...props}
47
- >
48
- {children}
49
- </m.p>
50
- );
51
- };
@@ -1,62 +0,0 @@
1
- import { P, match } from 'ts-pattern';
2
- import { parse, picklist } from 'valibot';
3
-
4
- import { useFocusOnMount } from '../useFocus';
5
- import { ChatInputProps } from './chat-input';
6
-
7
- export type BooleanChoicePayload = {
8
- type: 'boolean';
9
- config: { labels: { true: string; false: string } };
10
- key: string;
11
- value: 'true' | 'false';
12
- };
13
-
14
- const options = ['true', 'false'] as const;
15
- const AnswerSchema = picklist(options);
16
- const FIELD_NAME = 'answer';
17
-
18
- export const ChatInputBoolean = ({ input, onSubmitSuccess }: ChatInputProps<'boolean'>) => {
19
- const focusRef = useFocusOnMount();
20
-
21
- return (
22
- <form
23
- noValidate
24
- class="flex gap-2 items-center"
25
- onSubmit={e => {
26
- e.preventDefault();
27
-
28
- /** nativeEvent is a hidden property not exposed by JSX's typings */
29
- const value = match(e as any)
30
- .with(
31
- {
32
- nativeEvent: {
33
- submitter: P.select(P.union(P.instanceOf(HTMLButtonElement), P.instanceOf(HTMLInputElement))),
34
- },
35
- },
36
- submitter => {
37
- return submitter.value;
38
- },
39
- )
40
- .otherwise(() => {
41
- throw new Error('invalid form');
42
- });
43
- const answer = parse(AnswerSchema, value);
44
- onSubmitSuccess(answer);
45
- }}
46
- >
47
- {options.map((value, i) => {
48
- return (
49
- <button
50
- ref={i === 0 ? focusRef : null}
51
- type="submit"
52
- name={FIELD_NAME}
53
- value={value}
54
- class="flex-1 overflow-hidden rounded-2xl block px-2.5 py-2.5 selection:bg-transparent transition-all bg-lowest ring-transparent ring-0 focus-visible:ring-offset-2 focus-visible:ring-4 focus-visible:ring-accent-7 ease-expo-out duration-300 outline outline-2 outline-neutral-12/5 text-neutral-12 active:outline-accent-9 active:bg-accent-4 active:text-accent-11"
55
- >
56
- <p class="truncate text-center text-base">{input.config.labels[value]}</p>
57
- </button>
58
- );
59
- })}
60
- </form>
61
- );
62
- };
@@ -1,213 +0,0 @@
1
- import { invariant } from '@inploi/core/common';
2
- import clsx from 'clsx';
3
- import { ComponentProps } from 'preact';
4
- import { useState } from 'preact/hooks';
5
- import { FieldError } from 'react-hook-form';
6
- import { P, match } from 'ts-pattern';
7
- import { application } from '~/chatbot.state';
8
- import { isSubmissionOfType } from '~/chatbot.utils';
9
-
10
- import { InputError } from '../input-error';
11
- import { SendButton } from '../send-button';
12
- import { useFocusOnMount } from '../useFocus';
13
- import { ChatInputProps } from './chat-input';
14
-
15
- export type FileToUpload = { name: string; data: string; sizeKb: number };
16
- export type FilePayload = {
17
- type: 'file';
18
- config: { extensions: string[]; fileSizeLimitKib?: number; allowMultiple: boolean };
19
- key: string;
20
- value: FileToUpload[];
21
- };
22
-
23
- const toBase64 = (file: File) =>
24
- new Promise<string>((resolve, reject) => {
25
- const reader = new FileReader();
26
- reader.readAsDataURL(file);
27
- reader.onload = () => {
28
- if (!reader.result) return reject('No result from reader');
29
- return resolve(reader.result.toString());
30
- };
31
- reader.onerror = reject;
32
- });
33
-
34
- const kbToReadableSize = (kb: number) =>
35
- match(kb)
36
- .with(P.number.lte(1000), () => `${Math.round(kb)}KB`)
37
- .with(P.number.lt(1000 * 10), () => `${(kb / 1000).toFixed(1)}MB`)
38
- .otherwise(() => `${Math.round(kb / 1000)}MB`);
39
-
40
- const addFileSizesKb = (files: FileToUpload[]) => files.reduce((acc, cur) => acc + cur.sizeKb, 0);
41
-
42
- const isFileSubmission = isSubmissionOfType('file');
43
-
44
- const FILENAMES_TO_SHOW_QTY = 3;
45
-
46
- export const FileThumbnail = ({
47
- file,
48
- class: className,
49
- ...props
50
- }: ComponentProps<'div'> & { file: Pick<FileToUpload, 'name' | 'sizeKb'> }) => {
51
- const extension = file.name.split('.').pop();
52
- const fileName = file.name.replace(new RegExp(`.${extension}$`), '');
53
-
54
- return (
55
- <div
56
- class={clsx(
57
- 'bg-accent-1 max-w-full flex gap-2 px-3 py-2 text-sm rounded-lg outline outline-neutral-4 overflow-hidden',
58
- className,
59
- )}
60
- {...props}
61
- >
62
- <p aria-label="File name" class="overflow-hidden text-accent-12 flex flex-grow">
63
- <span class="block truncate">{fileName}</span>
64
- <span>.{extension}</span>
65
- </p>
66
-
67
- <p aria-label="File size" class="text-neutral-10">
68
- {kbToReadableSize(file.sizeKb)}
69
- </p>
70
- </div>
71
- );
72
- };
73
-
74
- const FilenameBadge = ({ class: className, ...props }: ComponentProps<'li'>) => (
75
- <li
76
- class={clsx(
77
- 'text-xs block outline outline-1 outline-neutral-6 text-neutral-11 bg-neutral-1 px-1 py-0.5 rounded-md',
78
- className,
79
- )}
80
- {...props}
81
- />
82
- );
83
- 1;
84
-
85
- export const ChatInputFile = ({ input, onSubmitSuccess }: ChatInputProps<'file'>) => {
86
- const submission = application.current$.value.application?.data.submissions[input.key];
87
- const [files, setFiles] = useState<FileToUpload[]>(isFileSubmission(submission) ? submission.value : []);
88
- const [error, setError] = useState<FieldError>();
89
- const hiddenFileCount = files.length - FILENAMES_TO_SHOW_QTY;
90
- const totalSize = addFileSizesKb(files);
91
- const focusRef = useFocusOnMount();
92
- return (
93
- <form
94
- class="flex flex-col gap-1"
95
- onSubmit={e => {
96
- e.preventDefault();
97
- setError(undefined);
98
- if (files.length === 0) {
99
- setError({ type: 'required', message: 'Please select a file' });
100
- }
101
- if (input.config.fileSizeLimitKib && totalSize > input.config.fileSizeLimitKib) {
102
- setError({
103
- type: 'max',
104
- message: `File size exceeds limit of ${kbToReadableSize(input.config.fileSizeLimitKib)}`,
105
- });
106
- }
107
- return onSubmitSuccess(files);
108
- }}
109
- >
110
- <div class="flex gap-2 items-center">
111
- <label
112
- ref={focusRef}
113
- for="dropzone-file"
114
- class="p-4 flex flex-col overflow-hidden items-center justify-center w-full h-48 border border-neutral-8 border-dashed rounded-2xl bg-neutral-2 cursor-pointer"
115
- >
116
- {files.length > 0 ? (
117
- <>
118
- <ul class="max-w-full flex gap-1 flex-wrap justify-center overflow-hidden p-1">
119
- {files.slice(0, FILENAMES_TO_SHOW_QTY).map(file => {
120
- const extension = file.name.split('.').pop();
121
- const fileName = file.name.replace(new RegExp(`.${extension}$`), '');
122
- return (
123
- <FilenameBadge class="flex overflow-hidden">
124
- <span class="block truncate">{fileName}</span>
125
- <span>.{extension}</span>
126
- </FilenameBadge>
127
- );
128
- })}
129
- {hiddenFileCount > 0 ? (
130
- <FilenameBadge>
131
- +{hiddenFileCount} file{hiddenFileCount !== 1 ? 's' : ''}
132
- </FilenameBadge>
133
- ) : null}
134
- </ul>
135
-
136
- <p class="text-xs text-neutral-11">
137
- {kbToReadableSize(totalSize)} {files.length > 1 ? 'total' : ''}
138
- </p>
139
- </>
140
- ) : (
141
- <div class="flex flex-col justify-center pt-5 pb-6 gap-4">
142
- <header class="flex flex-col gap-0 items-center">
143
- <svg
144
- class="w-8 h-8 text-neutral-11 mb-1"
145
- aria-hidden="true"
146
- xmlns="http://www.w3.org/2000/svg"
147
- fill="none"
148
- viewBox="0 0 20 16"
149
- >
150
- <path
151
- stroke="currentColor"
152
- stroke-linecap="round"
153
- stroke-linejoin="round"
154
- stroke-width="1.5"
155
- d="M13 13h3a3 3 0 0 0 0-6h-.025A5.56 5.56 0 0 0 16 6.5 5.5 5.5 0 0 0 5.207 5.021C5.137 5.017 5.071 5 5 5a4 4 0 0 0 0 8h2.167M10 15V6m0 0L8 8m2-2 2 2"
156
- />
157
- </svg>
158
- <p class="tracking-[-0.01em] text-neutral-12 dark:text-gray-400">
159
- {input.config.allowMultiple ? 'Select files' : 'Select a file'} to upload
160
- </p>
161
- {input.config.fileSizeLimitKib ? (
162
- <p class="text-xs text-neutral-10">(max {kbToReadableSize(input.config.fileSizeLimitKib)})</p>
163
- ) : null}
164
- </header>
165
- <aside class="flex flex-col gap-2 items-center">
166
- <p id="accepted-filetypes" class="sr-only">
167
- Accepted file extensions
168
- </p>
169
- <ul aria-describedby="accepted-filetypes" class="flex gap-2 flex-wrap justify-center">
170
- {input.config.extensions.map(ext => (
171
- <li class="text-[11px] tracking-wide uppercase outline ring-2 ring-lowest outline-1 outline-neutral-6 text-neutral-9 bg-neutral-1 px-1 py-0.5 rounded-md">
172
- {ext.replace('.', '')}
173
- </li>
174
- ))}
175
- </ul>
176
- </aside>
177
- </div>
178
- )}
179
-
180
- <input
181
- id="dropzone-file"
182
- onInput={async e => {
183
- invariant(e.target instanceof HTMLInputElement);
184
- const files = e.target.files ? Array.from(e.target.files) : [];
185
- const filesToUpload = await Promise.allSettled(
186
- files.map(async file => {
187
- const data = await toBase64(file);
188
- return {
189
- name: file.name,
190
- data: data,
191
- sizeKb: file.size / 1000,
192
- };
193
- }),
194
- );
195
- if (filesToUpload.some(({ status }) => status === 'rejected')) {
196
- return setError({ type: 'invalid', message: 'Invalid file' });
197
- }
198
- const validFiles = filesToUpload
199
- .map(promise => (promise.status === 'fulfilled' ? promise.value : null))
200
- .filter(Boolean) as FileToUpload[];
201
- setFiles(validFiles);
202
- }}
203
- multiple={input.config.allowMultiple}
204
- type="file"
205
- class="sr-only"
206
- />
207
- </label>
208
- <SendButton disabled={files.length === 0} />
209
- </div>
210
- {error && <InputError error={error} />}
211
- </form>
212
- );
213
- };
@@ -1,117 +0,0 @@
1
- import { valibotResolver } from '@hookform/resolvers/valibot';
2
- import { useForm } from 'react-hook-form';
3
- import { boolean, maxLength, minLength, object, record, transform } from 'valibot';
4
- import { application } from '~/chatbot.state';
5
- import { isSubmissionOfType } from '~/chatbot.utils';
6
-
7
- import { InputError } from '../input-error';
8
- import { SendButton } from '../send-button';
9
- import { useFocusOnMount } from '../useFocus';
10
- import { ChatInputProps } from './chat-input';
11
-
12
- export type MultipleChoicePayload = {
13
- type: 'multiple-choice';
14
- key: string;
15
- config: { options: { value: string; label: string }[]; minSelected?: number; maxSelected?: number };
16
- value: string[];
17
- };
18
-
19
- const submitIfSingleChecked = (form: HTMLFormElement) => {
20
- const formObj = Object.fromEntries(new FormData(form).entries());
21
- const isSingleChecked = Object.keys(formObj).length;
22
- if (!isSingleChecked) return;
23
- form.dispatchEvent(new Event('submit', { bubbles: true }));
24
- };
25
-
26
- const isMultipleChoiceSubmission = isSubmissionOfType('multiple-choice');
27
-
28
- const getResolver = (config: MultipleChoicePayload['config']) => {
29
- const length = {
30
- min: config.minSelected ?? 0,
31
- max: config.maxSelected ?? config.options.length,
32
- };
33
- return valibotResolver(
34
- object({
35
- checked: transform(
36
- record(boolean()),
37
- o =>
38
- Object.entries(o)
39
- .filter(([_, v]) => v)
40
- .map(([k, _]) => k),
41
- [
42
- maxLength(length.max, `Please select at most ${length.max} option${length.max !== 1 ? 's' : ''}`),
43
- minLength(length.min, `Please select at least ${length.min} option${length.min !== 1 ? 's' : ''}`),
44
- ],
45
- ),
46
- }),
47
- );
48
- };
49
-
50
- export const ChatInputMultipleChoice = ({ input, onSubmitSuccess }: ChatInputProps<'multiple-choice'>) => {
51
- const submission = application.current$.value.application?.data.submissions[input.key];
52
- const {
53
- register,
54
- handleSubmit,
55
- formState: { errors },
56
- } = useForm({
57
- defaultValues: {
58
- checked: isMultipleChoiceSubmission(submission)
59
- ? Object.fromEntries(submission.value.map(key => [key, true]))
60
- : {},
61
- },
62
- resolver: getResolver(input.config),
63
- });
64
- const focusRef = useFocusOnMount();
65
- const isSingleChoice = input.config.minSelected === 1 && input.config.maxSelected === 1;
66
-
67
- return (
68
- <form
69
- noValidate
70
- class="flex flex-col gap-1"
71
- onChange={e => {
72
- if (isSingleChoice) {
73
- submitIfSingleChecked(e.currentTarget);
74
- }
75
- }}
76
- onSubmit={handleSubmit(submission => {
77
- // react-hook-form does not play well with zod's transform
78
- const checked = submission.checked as unknown as string[];
79
- onSubmitSuccess(checked);
80
- })}
81
- >
82
- <div class="flex items-center gap-2">
83
- <div class="flex flex-wrap gap-3 flex-1 p-1 w-full">
84
- {input.config.options.map((option, i) => {
85
- const id = `checked.${option.value}` as const;
86
- const { ref: setRef, ...props } = register(id);
87
- return (
88
- <div key={option.value}>
89
- <input
90
- autoFocus={i === 0}
91
- ref={(e: HTMLInputElement | null) => {
92
- if (e && i === 0) {
93
- focusRef.current = e;
94
- }
95
- setRef(e);
96
- }}
97
- id={id}
98
- {...props}
99
- class="peer sr-only"
100
- type="checkbox"
101
- ></input>
102
- <label
103
- class="rounded-2xl block px-2.5 py-1 selection:bg-transparent transition-all bg-lowest ring-transparent ring-0 peer-focus-visible:ring-offset-2 peer-focus-visible:ring-4 peer-focus-visible:ring-accent-7 active:outline-neutral-10 ease-expo-out duration-300 outline outline-2 outline-neutral-12/5 text-neutral-11 peer-checked:outline-accent-9 peer-checked:bg-accent-4 peer-checked:text-accent-11"
104
- htmlFor={id}
105
- >
106
- {option.label}
107
- </label>
108
- </div>
109
- );
110
- })}
111
- </div>
112
- {!isSingleChoice && <SendButton />}
113
- </div>
114
- <InputError error={errors.checked?.root} />
115
- </form>
116
- );
117
- };