@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.
Files changed (47) hide show
  1. package/README.md +8 -0
  2. package/dist/api.js +656 -111
  3. package/dist/components/AppRequestCard.svelte +172 -0
  4. package/dist/components/ApplicantProgramList.svelte +184 -0
  5. package/dist/components/ApplicantProgramListTooltip.svelte +22 -0
  6. package/dist/components/ApplicantPromptPage.svelte +88 -0
  7. package/dist/components/ApplicationDetailsView.svelte +307 -0
  8. package/dist/components/ButtonLoadingIcon.svelte +2 -1
  9. package/dist/components/FieldCardCheckbox.svelte +328 -0
  10. package/dist/components/FieldCardRadio.svelte +320 -0
  11. package/dist/components/IntroPanel.svelte +41 -0
  12. package/dist/components/PeriodPanel.svelte +100 -0
  13. package/dist/components/QuestionnairePrompt.svelte +36 -0
  14. package/dist/components/RenderDisplayComponent.svelte +38 -0
  15. package/dist/components/ReviewerList.svelte +93 -0
  16. package/dist/components/StatusMessageList.svelte +35 -0
  17. package/dist/components/WarningIconYellow.svelte +20 -0
  18. package/dist/components/index.js +11 -0
  19. package/dist/components/types.js +1 -0
  20. package/dist/csv.js +21 -0
  21. package/dist/index.js +2 -0
  22. package/dist/status-utils.js +343 -0
  23. package/dist/stores/IStateStore.js +0 -1
  24. package/dist/typed-client/schema.graphql +564 -124
  25. package/dist/typed-client/schema.js +87 -23
  26. package/dist/typed-client/types.js +919 -454
  27. package/dist/util.js +12 -1
  28. package/package.json +39 -40
  29. package/dist/api.d.ts +0 -595
  30. package/dist/components/ButtonLoadingIcon.svelte.d.ts +0 -18
  31. package/dist/components/index.d.ts +0 -1
  32. package/dist/index.d.ts +0 -4
  33. package/dist/registry.d.ts +0 -138
  34. package/dist/stores/IStateStore.d.ts +0 -5
  35. package/dist/typed-client/index.d.ts +0 -25
  36. package/dist/typed-client/runtime/batcher.d.ts +0 -105
  37. package/dist/typed-client/runtime/createClient.d.ts +0 -17
  38. package/dist/typed-client/runtime/error.d.ts +0 -18
  39. package/dist/typed-client/runtime/fetcher.d.ts +0 -10
  40. package/dist/typed-client/runtime/generateGraphqlOperation.d.ts +0 -30
  41. package/dist/typed-client/runtime/index.d.ts +0 -11
  42. package/dist/typed-client/runtime/linkTypeMap.d.ts +0 -9
  43. package/dist/typed-client/runtime/typeSelection.d.ts +0 -28
  44. package/dist/typed-client/runtime/types.d.ts +0 -55
  45. package/dist/typed-client/schema.d.ts +0 -1483
  46. package/dist/typed-client/types.d.ts +0 -540
  47. 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}&nbsp;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}&nbsp;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>
@@ -1,4 +1,5 @@
1
- <script>import { ScreenReaderOnly } from "@txstate-mws/svelte-components";
1
+ <script lang="ts">
2
+ import { ScreenReaderOnly } from '@txstate-mws/svelte-components'
2
3
  </script>
3
4
 
4
5
  <svg style:display="none"></svg>
@@ -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>