@reqquest/ui 1.0.0 → 1.1.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 +8 -0
- package/dist/api.js +656 -111
- package/dist/components/AppRequestCard.svelte +172 -0
- package/dist/components/ApplicantProgramList.svelte +184 -0
- package/dist/components/ApplicantProgramListTooltip.svelte +22 -0
- package/dist/components/ApplicantPromptPage.svelte +88 -0
- package/dist/components/ApplicationDetailsView.svelte +307 -0
- package/dist/components/ButtonLoadingIcon.svelte +2 -1
- package/dist/components/FieldCardCheckbox.svelte +328 -0
- package/dist/components/FieldCardRadio.svelte +320 -0
- package/dist/components/IntroPanel.svelte +41 -0
- package/dist/components/PeriodPanel.svelte +100 -0
- package/dist/components/QuestionnairePrompt.svelte +36 -0
- package/dist/components/RenderDisplayComponent.svelte +38 -0
- package/dist/components/ReviewerList.svelte +93 -0
- package/dist/components/StatusMessageList.svelte +35 -0
- package/dist/components/WarningIconYellow.svelte +20 -0
- package/dist/components/index.js +11 -0
- package/dist/components/types.js +1 -0
- package/dist/csv.js +21 -0
- package/dist/index.js +2 -0
- package/dist/status-utils.js +343 -0
- package/dist/stores/IStateStore.js +0 -1
- package/dist/typed-client/schema.graphql +564 -124
- package/dist/typed-client/schema.js +87 -23
- package/dist/typed-client/types.js +919 -454
- package/dist/util.js +12 -1
- package/package.json +39 -40
- package/dist/api.d.ts +0 -595
- package/dist/components/ButtonLoadingIcon.svelte.d.ts +0 -18
- package/dist/components/index.d.ts +0 -1
- package/dist/index.d.ts +0 -4
- package/dist/registry.d.ts +0 -138
- package/dist/stores/IStateStore.d.ts +0 -5
- package/dist/typed-client/index.d.ts +0 -25
- package/dist/typed-client/runtime/batcher.d.ts +0 -105
- package/dist/typed-client/runtime/createClient.d.ts +0 -17
- package/dist/typed-client/runtime/error.d.ts +0 -18
- package/dist/typed-client/runtime/fetcher.d.ts +0 -10
- package/dist/typed-client/runtime/generateGraphqlOperation.d.ts +0 -30
- package/dist/typed-client/runtime/index.d.ts +0 -11
- package/dist/typed-client/runtime/linkTypeMap.d.ts +0 -9
- package/dist/typed-client/runtime/typeSelection.d.ts +0 -28
- package/dist/typed-client/runtime/types.d.ts +0 -55
- package/dist/typed-client/schema.d.ts +0 -1483
- package/dist/typed-client/types.d.ts +0 -540
- package/dist/util.d.ts +0 -2
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { UIRegistry } from '../registry.js'
|
|
3
|
+
import { getAppRequestStatusInfo, getApplicationStatusInfo, applicantRequirementTypes, reviewRequirementTypes } from '../status-utils.js'
|
|
4
|
+
import type { Scalars } from '../typed-client/schema'
|
|
5
|
+
import { enumRequirementType, type RequirementType } from '../typed-client/index.js'
|
|
6
|
+
import { Panel, TagSet } from '@txstate-mws/carbon-svelte'
|
|
7
|
+
import Button from "carbon-components-svelte/src/Button/Button.svelte";
|
|
8
|
+
import InlineNotification from "carbon-components-svelte/src/Notification/InlineNotification.svelte";
|
|
9
|
+
import Tooltip from "carbon-components-svelte/src/Tooltip/Tooltip.svelte";
|
|
10
|
+
import Edit from 'carbon-icons-svelte/lib/Edit.svelte'
|
|
11
|
+
import type { AnsweredPrompt, PromptSection, AppRequestForDetails, ApplicationForDetails } from './types'
|
|
12
|
+
import RenderDisplayComponent from './RenderDisplayComponent.svelte'
|
|
13
|
+
import ApplicantProgramList from './ApplicantProgramList.svelte'
|
|
14
|
+
import WarningIconYellow from './WarningIconYellow.svelte'
|
|
15
|
+
|
|
16
|
+
// TODO: design alignement could reduce props here
|
|
17
|
+
export let appRequest: AppRequestForDetails
|
|
18
|
+
export let applications: ApplicationForDetails[]
|
|
19
|
+
export let appData: Scalars['JsonData'] = {}
|
|
20
|
+
export let prequalPrompts: AnsweredPrompt[] | undefined = undefined
|
|
21
|
+
export let postqualPrompts: AnsweredPrompt[] | undefined = undefined
|
|
22
|
+
export let loading = false
|
|
23
|
+
export let uiRegistry: UIRegistry
|
|
24
|
+
export let title = 'View your application'
|
|
25
|
+
export let subtitle = 'Select document names to preview them.'
|
|
26
|
+
export let expandable = true
|
|
27
|
+
export let showWarningsInline = false
|
|
28
|
+
export let showCorrectionsInline = false
|
|
29
|
+
export let showAppRequestStatus = true
|
|
30
|
+
export let statusDisplay: 'tags' | 'icons' = 'tags'
|
|
31
|
+
export let showTooltipsAsText = false
|
|
32
|
+
|
|
33
|
+
const CORRECTABLE_STATUSES = ['STARTED', 'READY_TO_SUBMIT', 'DISQUALIFIED']
|
|
34
|
+
|
|
35
|
+
$: canMakeCorrections = CORRECTABLE_STATUSES.includes(appRequest.status)
|
|
36
|
+
|
|
37
|
+
// Group prompts by sections, with reviewer prompts nested within application sections
|
|
38
|
+
$: sections = (() => {
|
|
39
|
+
const sections: PromptSection[] = []
|
|
40
|
+
|
|
41
|
+
// General Questions === PREQUAL prompts
|
|
42
|
+
if (prequalPrompts?.length) {
|
|
43
|
+
sections.push({ title: 'General Questions', prompts: prequalPrompts })
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Application-Specific Questions with nested Reviewer Questions
|
|
47
|
+
for (const application of applications) {
|
|
48
|
+
const applicantPrompts = application.requirements
|
|
49
|
+
.filter(r => applicantRequirementTypes.has(r.type))
|
|
50
|
+
.flatMap(r => r.prompts)
|
|
51
|
+
const reviewerPrompts = application.requirements
|
|
52
|
+
.filter(r => reviewRequirementTypes.has(r.type))
|
|
53
|
+
.flatMap(r => r.prompts)
|
|
54
|
+
const workflowPrompts = application.requirements
|
|
55
|
+
.filter(r => r.type === enumRequirementType.WORKFLOW)
|
|
56
|
+
.flatMap(r => r.prompts)
|
|
57
|
+
|
|
58
|
+
if (applicantPrompts.length || reviewerPrompts.length || workflowPrompts.length) {
|
|
59
|
+
const subsections: PromptSection[] = []
|
|
60
|
+
if (reviewerPrompts.length) subsections.push({ title: 'Reviewer Questions', prompts: reviewerPrompts })
|
|
61
|
+
if (workflowPrompts.length) subsections.push({ title: 'Workflow Questions', prompts: workflowPrompts })
|
|
62
|
+
sections.push({
|
|
63
|
+
title: application.title,
|
|
64
|
+
prompts: applicantPrompts,
|
|
65
|
+
subsections,
|
|
66
|
+
applicationStatus: application.status
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Additional Questions === POSTQUAL prompts
|
|
72
|
+
if (postqualPrompts?.length) {
|
|
73
|
+
sections.push({ title: 'Additional Questions', prompts: postqualPrompts })
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return sections
|
|
77
|
+
})()
|
|
78
|
+
|
|
79
|
+
function hasWarning (prompt: AnsweredPrompt) {
|
|
80
|
+
return prompt.statusReasons.some(r => r.status === 'WARNING' || r.status === 'DISQUALIFYING')
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function getWarnings (prompt: AnsweredPrompt) {
|
|
84
|
+
return prompt.statusReasons.filter(r => r.status === 'WARNING' || r.status === 'DISQUALIFYING')
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function needsCorrection (prompt: AnsweredPrompt) {
|
|
88
|
+
return prompt.invalidated && prompt.invalidatedReason
|
|
89
|
+
}
|
|
90
|
+
</script>
|
|
91
|
+
|
|
92
|
+
{#if appRequest}
|
|
93
|
+
<div class="[ my-6 ] application-details flow">
|
|
94
|
+
<!-- Application Status Panel -->
|
|
95
|
+
<header class="[ mb-12 ] app-view-intro text-center">
|
|
96
|
+
<h2 class="[ text-xl mb-3 ]">{title}</h2>
|
|
97
|
+
<p class="app-view-subtitle mb-3">{subtitle}</p>
|
|
98
|
+
</header>
|
|
99
|
+
<section class="prompt-section">
|
|
100
|
+
<div class="status-content">
|
|
101
|
+
{#if showAppRequestStatus}
|
|
102
|
+
<dl class="status-list-item [ flex items-center justify-between px-2 py-3 border-b ]">
|
|
103
|
+
<dt class="status-list-label font-medium">Application Status</dt>
|
|
104
|
+
<dd class="px-2">
|
|
105
|
+
<TagSet tags={[{ label: getAppRequestStatusInfo(appRequest.status).label, type: getAppRequestStatusInfo(appRequest.status).color }]} />
|
|
106
|
+
</dd>
|
|
107
|
+
</dl>
|
|
108
|
+
{/if}
|
|
109
|
+
|
|
110
|
+
<!-- Application Status List -->
|
|
111
|
+
<ApplicantProgramList {applications} viewMode={statusDisplay === 'tags'} {showTooltipsAsText} />
|
|
112
|
+
</div>
|
|
113
|
+
</section>
|
|
114
|
+
|
|
115
|
+
<!-- Prompt Sections -->
|
|
116
|
+
{#if loading}
|
|
117
|
+
<Panel title="Loading...">
|
|
118
|
+
<p>Loading prompt data...</p>
|
|
119
|
+
</Panel>
|
|
120
|
+
{:else if sections.length > 0}
|
|
121
|
+
{#each sections as section (section.title)}
|
|
122
|
+
<Panel title={section.title} {expandable} expanded>
|
|
123
|
+
<svelte:fragment slot="headerLeft">
|
|
124
|
+
{#if section.applicationStatus}
|
|
125
|
+
<TagSet tags={[{ label: getApplicationStatusInfo(section.applicationStatus).label, type: getApplicationStatusInfo(section.applicationStatus).color }]} />
|
|
126
|
+
{/if}
|
|
127
|
+
</svelte:fragment>
|
|
128
|
+
{#if section.prompts.length}
|
|
129
|
+
<dl class="prompt-list">
|
|
130
|
+
{#each section.prompts as prompt (prompt.id)}
|
|
131
|
+
{@const def = uiRegistry.getPrompt(prompt.key)}
|
|
132
|
+
<dt class="prompt-term [ font-medium ]">
|
|
133
|
+
{#if showWarningsInline && hasWarning(prompt)}
|
|
134
|
+
<Tooltip align="start">
|
|
135
|
+
<div class="icon" slot="icon">
|
|
136
|
+
<WarningIconYellow size={16} />
|
|
137
|
+
</div>
|
|
138
|
+
{#each getWarnings(prompt) as r, i (i)}
|
|
139
|
+
<p>
|
|
140
|
+
{r.status === 'WARNING' ? 'Warning' : 'Disqualifying'}{#if section.title !== r.programName} for {r.programName}{/if}<br>
|
|
141
|
+
{r.statusReason}
|
|
142
|
+
</p>
|
|
143
|
+
{/each}
|
|
144
|
+
</Tooltip>
|
|
145
|
+
{/if}
|
|
146
|
+
{#if showCorrectionsInline && canMakeCorrections && needsCorrection(prompt)}
|
|
147
|
+
<Tooltip align="start">
|
|
148
|
+
<div class="icon" slot="icon">
|
|
149
|
+
<WarningIconYellow size={16} />
|
|
150
|
+
</div>
|
|
151
|
+
<p>Correction needed<br>{prompt.invalidatedReason}</p>
|
|
152
|
+
</Tooltip>
|
|
153
|
+
{/if}
|
|
154
|
+
{prompt.title}
|
|
155
|
+
</dt>
|
|
156
|
+
<dd class="prompt-answer flow" class:large={def?.displayMode === 'large'}>
|
|
157
|
+
<RenderDisplayComponent {def} appRequestId={appRequest.id} appData={appData} prompt={prompt} configData={prompt.configurationData} gatheredConfigData={prompt.gatheredConfigData} />
|
|
158
|
+
{#if showCorrectionsInline && canMakeCorrections && needsCorrection(prompt)}
|
|
159
|
+
<Button kind="ghost" size="small" icon={Edit} iconDescription="Edit this answer" href={`/requests/${appRequest.id}/apply/${prompt.id}`} class="edit-button" />
|
|
160
|
+
{/if}
|
|
161
|
+
</dd>
|
|
162
|
+
{#if showCorrectionsInline && canMakeCorrections && needsCorrection(prompt)}
|
|
163
|
+
<div class="correction-notice">
|
|
164
|
+
<InlineNotification kind="warning-alt" title="Correction needed" subtitle={prompt.invalidatedReason ?? ''} hideCloseButton lowContrast />
|
|
165
|
+
</div>
|
|
166
|
+
{/if}
|
|
167
|
+
{/each}
|
|
168
|
+
</dl>
|
|
169
|
+
{/if}
|
|
170
|
+
|
|
171
|
+
<!-- Nested subsections (e.g., Reviewer Questions) -->
|
|
172
|
+
{#if section.subsections}
|
|
173
|
+
{#each section.subsections as subsection (subsection.title)}
|
|
174
|
+
<Panel title={subsection.title} {expandable} expanded>
|
|
175
|
+
<dl class="prompt-list">
|
|
176
|
+
{#each subsection.prompts as prompt (prompt.id)}
|
|
177
|
+
{@const def = uiRegistry.getPrompt(prompt.key)}
|
|
178
|
+
<dt class="prompt-term [ font-medium ]">
|
|
179
|
+
{#if showWarningsInline && hasWarning(prompt)}
|
|
180
|
+
<Tooltip align="start">
|
|
181
|
+
<div class="icon" slot="icon">
|
|
182
|
+
<WarningIconYellow size={16} />
|
|
183
|
+
</div>
|
|
184
|
+
{#each getWarnings(prompt) as r, i (i)}
|
|
185
|
+
<p>
|
|
186
|
+
{r.status === 'WARNING' ? 'Warning' : 'Disqualifying'}{#if section.title !== r.programName} for {r.programName}{/if}<br>
|
|
187
|
+
{r.statusReason}
|
|
188
|
+
</p>
|
|
189
|
+
{/each}
|
|
190
|
+
</Tooltip>
|
|
191
|
+
{/if}
|
|
192
|
+
{#if showCorrectionsInline && canMakeCorrections && needsCorrection(prompt)}
|
|
193
|
+
<Tooltip align="start">
|
|
194
|
+
<div class="icon" slot="icon">
|
|
195
|
+
<WarningIconYellow size={16} />
|
|
196
|
+
</div>
|
|
197
|
+
<p>Correction needed<br>{prompt.invalidatedReason}</p>
|
|
198
|
+
</Tooltip>
|
|
199
|
+
{/if}
|
|
200
|
+
{prompt.title}
|
|
201
|
+
</dt>
|
|
202
|
+
<dd class="prompt-answer flow" class:large={def?.displayMode === 'large'}>
|
|
203
|
+
<RenderDisplayComponent {def} appRequestId={appRequest.id} appData={appData} prompt={prompt} configData={prompt.configurationData} gatheredConfigData={prompt.gatheredConfigData} />
|
|
204
|
+
{#if showCorrectionsInline && canMakeCorrections && needsCorrection(prompt)}
|
|
205
|
+
<Button kind="ghost" size="small" icon={Edit} iconDescription="Edit this answer" href={`/requests/${appRequest.id}/apply/${prompt.id}`} class="edit-button" />
|
|
206
|
+
{/if}
|
|
207
|
+
</dd>
|
|
208
|
+
{#if showCorrectionsInline && canMakeCorrections && needsCorrection(prompt)}
|
|
209
|
+
<div class="correction-notice">
|
|
210
|
+
<InlineNotification kind="warning-alt" title="Correction needed" subtitle={prompt.invalidatedReason ?? ''} hideCloseButton lowContrast />
|
|
211
|
+
</div>
|
|
212
|
+
{/if}
|
|
213
|
+
{/each}
|
|
214
|
+
</dl>
|
|
215
|
+
</Panel>
|
|
216
|
+
{/each}
|
|
217
|
+
{/if}
|
|
218
|
+
</Panel>
|
|
219
|
+
{/each}
|
|
220
|
+
{:else}
|
|
221
|
+
<Panel title="Prompts and Answers">
|
|
222
|
+
<p>No prompts available for this application.</p>
|
|
223
|
+
</Panel>
|
|
224
|
+
{/if}
|
|
225
|
+
|
|
226
|
+
<slot name="footer" />
|
|
227
|
+
</div>
|
|
228
|
+
{:else}
|
|
229
|
+
<p>No application selected</p>
|
|
230
|
+
{/if}
|
|
231
|
+
|
|
232
|
+
<style>
|
|
233
|
+
.app-view-subtitle {
|
|
234
|
+
color: var(--cds-text-02);
|
|
235
|
+
/* margin-top: -0.5rem; */
|
|
236
|
+
margin-bottom: 1rem;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
.status-list-item {
|
|
240
|
+
border-bottom: 1px solid var(--cds-border-subtle);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
.prompt-answer :global(dl) {
|
|
244
|
+
padding-block-start:1em;
|
|
245
|
+
display:grid;
|
|
246
|
+
grid-template-columns: 1fr 1fr;
|
|
247
|
+
row-gap:0.5rem;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
.status-content dl {
|
|
251
|
+
padding-block-start:1em;
|
|
252
|
+
display:grid;
|
|
253
|
+
grid-template-columns: 2fr 1fr;
|
|
254
|
+
row-gap:0.5rem;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
.status-list-item {
|
|
258
|
+
border-color: var(--cds-border-subtle);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
.status-list-label {
|
|
262
|
+
color: var(--cds-text-01);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
.prompt-list {
|
|
266
|
+
display: grid;
|
|
267
|
+
grid-template-columns: 1fr 1fr;
|
|
268
|
+
align-items: stretch;
|
|
269
|
+
row-gap: 0.5rem;
|
|
270
|
+
}
|
|
271
|
+
.prompt-list dt, .prompt-list dd {
|
|
272
|
+
padding-bottom: 0.5rem;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
.prompt-term {
|
|
276
|
+
display: flex;
|
|
277
|
+
gap:1em;
|
|
278
|
+
color: var(--cds-text-01);
|
|
279
|
+
border-bottom: 1px solid var(--cds-border-subtle);
|
|
280
|
+
}
|
|
281
|
+
.prompt-term:has(+ .prompt-answer.large) {
|
|
282
|
+
border-bottom: none;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
.prompt-answer {
|
|
286
|
+
color: var(--cds-text-02);
|
|
287
|
+
border-bottom: 1px solid var(--cds-border-subtle);
|
|
288
|
+
}
|
|
289
|
+
.prompt-answer.large {
|
|
290
|
+
grid-column: span 2;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
.correction-notice {
|
|
294
|
+
grid-column: span 2;
|
|
295
|
+
margin-bottom: 0.5rem;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
.correction-notice :global(.bx--inline-notification) {
|
|
299
|
+
max-width: 100%;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
.prompt-answer :global(.edit-button) {
|
|
303
|
+
float: right;
|
|
304
|
+
margin-top: -0.5rem;
|
|
305
|
+
--cds-icon-01: var(--cds-link-01);
|
|
306
|
+
}
|
|
307
|
+
</style>
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { CardSelectItem } from './FieldCardRadio.svelte'
|
|
3
|
+
import { Card, CardGrid, FormInlineNotification } from '@txstate-mws/carbon-svelte'
|
|
4
|
+
import { FORM_CONTEXT, FORM_INHERITED_PATH, Field, type FormStore } from '@txstate-mws/svelte-forms'
|
|
5
|
+
import { Store } from '@txstate-mws/svelte-store'
|
|
6
|
+
import { createEventDispatcher, getContext } from 'svelte'
|
|
7
|
+
import { equal, get, isNotBlank } from 'txstate-utils'
|
|
8
|
+
|
|
9
|
+
const dispatch = createEventDispatcher()
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Path to the form field in the data structure
|
|
13
|
+
* @type {string}
|
|
14
|
+
*/
|
|
15
|
+
export let path: string
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Array of card options
|
|
19
|
+
* @type {CardSelectItem[]}
|
|
20
|
+
*/
|
|
21
|
+
export let items: CardSelectItem[]
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Whether the field group is conditional
|
|
25
|
+
* @type {boolean}
|
|
26
|
+
* @default true
|
|
27
|
+
*/
|
|
28
|
+
export let conditional = true
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Set to true to prevent null/undefined values (will default to empty array)
|
|
32
|
+
* @type {boolean}
|
|
33
|
+
* @default false
|
|
34
|
+
*/
|
|
35
|
+
export let notNull = false
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Default selected values (array)
|
|
39
|
+
* @type {any[]}
|
|
40
|
+
* @default []
|
|
41
|
+
*/
|
|
42
|
+
export let defaultValue: any[] = []
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Text to display in the fieldset legend
|
|
46
|
+
* @type {string | undefined}
|
|
47
|
+
* @default undefined
|
|
48
|
+
*/
|
|
49
|
+
export let legendText: string | undefined = undefined
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* When true, the field is marked as required in the UI. This is only for visual indication
|
|
53
|
+
* and does not affect validation. Validation is handled by the API that receives the data.
|
|
54
|
+
* @type {boolean}
|
|
55
|
+
* @default false
|
|
56
|
+
*/
|
|
57
|
+
export let required = false
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* CSS size for the grid minimum item width (like CardGrid)
|
|
61
|
+
* @type {string}
|
|
62
|
+
* @default '20rem'
|
|
63
|
+
*/
|
|
64
|
+
export let cardSize = '20rem'
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Gap between cards in the grid
|
|
68
|
+
* @type {string}
|
|
69
|
+
* @default '16px'
|
|
70
|
+
*/
|
|
71
|
+
export let gap = '16px'
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Custom function to serialize individual item values for form submission.
|
|
75
|
+
* Each item value is serialized independently before being stored in the array.
|
|
76
|
+
* @type {((value: any) => string) | undefined}
|
|
77
|
+
* @default undefined
|
|
78
|
+
*/
|
|
79
|
+
export let serialize: ((value: any) => string) | undefined = undefined
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Custom function to deserialize individual item values from form data.
|
|
83
|
+
* Each item value is deserialized independently after being retrieved from the array.
|
|
84
|
+
* @type {((value: string) => any) | undefined}
|
|
85
|
+
* @default undefined
|
|
86
|
+
*/
|
|
87
|
+
export let deserialize: ((value: string) => any) | undefined = undefined
|
|
88
|
+
|
|
89
|
+
const store = getContext<FormStore>(FORM_CONTEXT)
|
|
90
|
+
const inheritedPath = getContext<string>(FORM_INHERITED_PATH)
|
|
91
|
+
const finalPath = [inheritedPath, path].filter(isNotBlank).join('.')
|
|
92
|
+
|
|
93
|
+
const itemStore = new Store(items)
|
|
94
|
+
$: itemStore.set(items)
|
|
95
|
+
|
|
96
|
+
// Array serialization wrappers - apply serialize/deserialize to each item
|
|
97
|
+
function arraySerialize (vals: any): any {
|
|
98
|
+
return serialize ? vals.map(serialize) : vals
|
|
99
|
+
}
|
|
100
|
+
function arrayDeserialize (vals: any): any {
|
|
101
|
+
return deserialize ? vals.map(deserialize) : vals
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function isSelected (itemValue: any, currentValues: any[] | undefined | null): boolean {
|
|
105
|
+
if (!currentValues) return false
|
|
106
|
+
return currentValues.some(v => equal(v, itemValue))
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function toggleSelect (item: CardSelectItem, currentValues: any[] | undefined | null, setVal: (val: any) => void) {
|
|
110
|
+
return () => {
|
|
111
|
+
if (item.disabled) return
|
|
112
|
+
const current = currentValues ?? []
|
|
113
|
+
const itemVal = item.value
|
|
114
|
+
let newValue: any[]
|
|
115
|
+
|
|
116
|
+
if (isSelected(itemVal, current)) {
|
|
117
|
+
newValue = current.filter(v => !equal(v, itemVal))
|
|
118
|
+
} else {
|
|
119
|
+
newValue = [...current, itemVal]
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
setVal(newValue)
|
|
123
|
+
dispatch('update', newValue)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function reactToItems (..._: any[]) {
|
|
128
|
+
if (!items.length) {
|
|
129
|
+
return await store.setField(finalPath, [], { notDirty: true })
|
|
130
|
+
}
|
|
131
|
+
const val = get($store.data, finalPath) as any[] | undefined
|
|
132
|
+
if (val == null) {
|
|
133
|
+
return await store.setField(finalPath, [], { notDirty: true })
|
|
134
|
+
}
|
|
135
|
+
if (val.length > 0) {
|
|
136
|
+
const validValues = val.filter(v => items.some(item => equal(item.value, v)))
|
|
137
|
+
if (validValues.length !== val.length) {
|
|
138
|
+
await store.setField(finalPath, validValues, { notDirty: true })
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
$: reactToItems($itemStore).catch(console.error)
|
|
143
|
+
|
|
144
|
+
// Keyboard navigation
|
|
145
|
+
let activeIdx = 0
|
|
146
|
+
const cardElements: HTMLDivElement[] = []
|
|
147
|
+
|
|
148
|
+
function findNextEnabled (startIdx: number, direction: 1 | -1): number {
|
|
149
|
+
let idx = startIdx
|
|
150
|
+
let attempts = 0
|
|
151
|
+
while (attempts < items.length) {
|
|
152
|
+
idx = (idx + direction + items.length) % items.length
|
|
153
|
+
if (!items[idx]?.disabled) return idx
|
|
154
|
+
attempts++
|
|
155
|
+
}
|
|
156
|
+
return startIdx // All disabled
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function focusCard (idx: number) {
|
|
160
|
+
if (idx >= 0 && idx < items.length && !items[idx]?.disabled) {
|
|
161
|
+
activeIdx = idx
|
|
162
|
+
cardElements[idx]?.focus()
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function handleKeyDown (e: KeyboardEvent, idx: number, currentValues: any[], setVal: (val: any) => void) {
|
|
167
|
+
switch (e.key) {
|
|
168
|
+
case 'ArrowRight':
|
|
169
|
+
case 'ArrowDown':
|
|
170
|
+
e.preventDefault()
|
|
171
|
+
focusCard(findNextEnabled(idx, 1))
|
|
172
|
+
break
|
|
173
|
+
case 'ArrowLeft':
|
|
174
|
+
case 'ArrowUp':
|
|
175
|
+
e.preventDefault()
|
|
176
|
+
focusCard(findNextEnabled(idx, -1))
|
|
177
|
+
break
|
|
178
|
+
case ' ':
|
|
179
|
+
case 'Enter':
|
|
180
|
+
e.preventDefault()
|
|
181
|
+
if (!items[idx]?.disabled) {
|
|
182
|
+
toggleSelect(items[idx], currentValues, setVal)()
|
|
183
|
+
}
|
|
184
|
+
break
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
</script>
|
|
188
|
+
<!--
|
|
189
|
+
@component
|
|
190
|
+
|
|
191
|
+
A form field component that displays a grid of selectable cards (multiple selection).
|
|
192
|
+
Similar to FieldCardRadio but allows selecting multiple cards (checkbox behavior).
|
|
193
|
+
|
|
194
|
+
The form value is an array of selected item values.
|
|
195
|
+
|
|
196
|
+
By default this component expects values to be strings. If you wish to use other types of values, you
|
|
197
|
+
must provide a `serialize` and `deserialize` function that handle individual item values.
|
|
198
|
+
|
|
199
|
+
If your `items` are not ready on first load (e.g. they're being loaded from an API fetch), you must
|
|
200
|
+
place this field inside an `{#if}` block until they are ready.
|
|
201
|
+
-->
|
|
202
|
+
<Field {path} {notNull} {conditional} {defaultValue} serialize={arraySerialize} deserialize={arrayDeserialize} let:messages let:value let:invalid let:onBlur let:setVal>
|
|
203
|
+
<div on:focusin={() => dispatch('focus')} on:focusout={() => { onBlur(); dispatch('blur') }}>
|
|
204
|
+
<fieldset class="card-select-fieldset">
|
|
205
|
+
{#if legendText}
|
|
206
|
+
<legend class="bx--label">{legendText}{#if required} <span aria-hidden="true"> *</span>{/if}</legend>
|
|
207
|
+
{/if}
|
|
208
|
+
<CardGrid {cardSize} {gap}>
|
|
209
|
+
{#each $itemStore as item, index (item.value)}
|
|
210
|
+
{@const selected = isSelected(item.value, value)}
|
|
211
|
+
<div
|
|
212
|
+
bind:this={cardElements[index]}
|
|
213
|
+
class="selectable-card"
|
|
214
|
+
class:selected
|
|
215
|
+
class:disabled={item.disabled}
|
|
216
|
+
role="checkbox"
|
|
217
|
+
aria-checked={selected}
|
|
218
|
+
aria-disabled={item.disabled}
|
|
219
|
+
tabindex={item.disabled ? -1 : (index === activeIdx ? 0 : -1)}
|
|
220
|
+
on:click={toggleSelect(item, value, setVal)}
|
|
221
|
+
on:keydown={e => handleKeyDown(e, index, value ?? [], setVal)}
|
|
222
|
+
>
|
|
223
|
+
<Card
|
|
224
|
+
title={item.title}
|
|
225
|
+
subhead={item.subhead}
|
|
226
|
+
image={item.image}
|
|
227
|
+
tags={item.tags ?? []}
|
|
228
|
+
actions={[]}
|
|
229
|
+
navigations={[]}
|
|
230
|
+
>
|
|
231
|
+
<slot {item} {selected} {index} />
|
|
232
|
+
</Card>
|
|
233
|
+
<div class="selection-indicator justify-center">
|
|
234
|
+
<span class="checkbox-square" class:checked={selected}>
|
|
235
|
+
{#if selected}
|
|
236
|
+
<svg viewBox="0 0 16 16" fill="currentColor">
|
|
237
|
+
<path d="M6.5 11.5L3 8l1-1 2.5 2.5L12 4l1 1z"/>
|
|
238
|
+
</svg>
|
|
239
|
+
{/if}
|
|
240
|
+
</span>
|
|
241
|
+
<span class="selection-label">{item.selectionLabel ?? 'Choose this option'}</span>
|
|
242
|
+
</div>
|
|
243
|
+
</div>
|
|
244
|
+
{/each}
|
|
245
|
+
</CardGrid>
|
|
246
|
+
</fieldset>
|
|
247
|
+
</div>
|
|
248
|
+
{#each messages as message (message)}
|
|
249
|
+
<FormInlineNotification {message} />
|
|
250
|
+
{/each}
|
|
251
|
+
</Field>
|
|
252
|
+
|
|
253
|
+
<style>
|
|
254
|
+
.card-select-fieldset {
|
|
255
|
+
border: none;
|
|
256
|
+
padding: 0;
|
|
257
|
+
margin: 0;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
.selectable-card {
|
|
261
|
+
cursor: pointer;
|
|
262
|
+
border: 2px solid transparent;
|
|
263
|
+
border-radius: 4px;
|
|
264
|
+
outline: none;
|
|
265
|
+
transition: border-color 0.15s ease;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
.selectable-card:focus-visible {
|
|
269
|
+
outline: 2px solid var(--cds-focus, #0f62fe);
|
|
270
|
+
outline-offset: 2px;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
.selectable-card:hover:not(.disabled) {
|
|
274
|
+
border-color: var(--cds-interactive-03, #0353e9);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
.selectable-card.selected {
|
|
278
|
+
border-color: var(--cds-interactive-01, #0f62fe);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
.selectable-card.disabled {
|
|
282
|
+
cursor: not-allowed;
|
|
283
|
+
opacity: 0.5;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
.selection-indicator {
|
|
287
|
+
display: flex;
|
|
288
|
+
align-items: center;
|
|
289
|
+
gap: 0.5rem;
|
|
290
|
+
padding: 0.75rem 1rem;
|
|
291
|
+
background-color: var(--cds-ui-01, #f4f4f4);
|
|
292
|
+
border-top: 1px solid var(--cds-ui-03, #e0e0e0);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
.checkbox-square {
|
|
296
|
+
width: 1.125rem;
|
|
297
|
+
height: 1.125rem;
|
|
298
|
+
border: 2px solid var(--cds-icon-01, #161616);
|
|
299
|
+
border-radius: 2px;
|
|
300
|
+
flex-shrink: 0;
|
|
301
|
+
display: flex;
|
|
302
|
+
align-items: center;
|
|
303
|
+
justify-content: center;
|
|
304
|
+
background-color: transparent;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
.checkbox-square.checked {
|
|
308
|
+
background-color: var(--cds-interactive-01, #0f62fe);
|
|
309
|
+
border-color: var(--cds-interactive-01, #0f62fe);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
.checkbox-square svg {
|
|
313
|
+
width: 0.75rem;
|
|
314
|
+
height: 0.75rem;
|
|
315
|
+
color: white;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
.selection-label {
|
|
319
|
+
font-size: 0.875rem;
|
|
320
|
+
color: var(--cds-text-01, #161616);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/* Remove Card's default border when inside selectable-card */
|
|
324
|
+
.selectable-card :global(.card) {
|
|
325
|
+
border: none;
|
|
326
|
+
box-shadow: none;
|
|
327
|
+
}
|
|
328
|
+
</style>
|