@cratis/scene.blueprint.default 4.35.0 → 4.37.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 (175) hide show
  1. package/ComponentName.ts +21 -0
  2. package/defaultBlueprintComponents.ts +8 -1
  3. package/dist/cjs/ComponentName.js +7 -0
  4. package/dist/cjs/ComponentName.js.map +1 -1
  5. package/dist/cjs/defaultBlueprint.js +2 -0
  6. package/dist/cjs/defaultBlueprint.js.map +1 -1
  7. package/dist/cjs/defaultBlueprintComponentMetadata.js +2 -0
  8. package/dist/cjs/defaultBlueprintComponentMetadata.js.map +1 -1
  9. package/dist/cjs/defaultBlueprintComponents.js +15 -1
  10. package/dist/cjs/defaultBlueprintComponents.js.map +1 -1
  11. package/dist/cjs/foundation/ActivityFeed.js +122 -0
  12. package/dist/cjs/foundation/ActivityFeed.js.map +1 -0
  13. package/dist/cjs/foundation/ApprovalChainStepper.js +130 -0
  14. package/dist/cjs/foundation/ApprovalChainStepper.js.map +1 -0
  15. package/dist/cjs/foundation/AvatarStack.js +73 -0
  16. package/dist/cjs/foundation/AvatarStack.js.map +1 -0
  17. package/dist/cjs/foundation/ChecklistSection.js +174 -0
  18. package/dist/cjs/foundation/ChecklistSection.js.map +1 -0
  19. package/dist/cjs/foundation/FileUploader.js +218 -0
  20. package/dist/cjs/foundation/FileUploader.js.map +1 -0
  21. package/dist/cjs/foundation/FileUploaderHandler.js +17 -0
  22. package/dist/cjs/foundation/FileUploaderHandler.js.map +1 -0
  23. package/dist/cjs/foundation/OrgTree.js +188 -0
  24. package/dist/cjs/foundation/OrgTree.js.map +1 -0
  25. package/dist/cjs/foundation/PermissionMatrix.js +136 -0
  26. package/dist/cjs/foundation/PermissionMatrix.js.map +1 -0
  27. package/dist/cjs/foundation/activityModel.js +63 -0
  28. package/dist/cjs/foundation/activityModel.js.map +1 -0
  29. package/dist/cjs/foundation/approvalModel.js +87 -0
  30. package/dist/cjs/foundation/approvalModel.js.map +1 -0
  31. package/dist/cjs/foundation/avatarModel.js +42 -0
  32. package/dist/cjs/foundation/avatarModel.js.map +1 -0
  33. package/dist/cjs/foundation/checklistModel.js +59 -0
  34. package/dist/cjs/foundation/checklistModel.js.map +1 -0
  35. package/dist/cjs/foundation/formatting.js +11 -0
  36. package/dist/cjs/foundation/formatting.js.map +1 -1
  37. package/dist/cjs/foundation/foundationComponentMetadata.js +47 -0
  38. package/dist/cjs/foundation/foundationComponentMetadata.js.map +1 -1
  39. package/dist/cjs/foundation/foundationDescriptors.js +246 -0
  40. package/dist/cjs/foundation/foundationDescriptors.js.map +1 -1
  41. package/dist/cjs/foundation/foundationSamples.js +230 -0
  42. package/dist/cjs/foundation/foundationSamples.js.map +1 -1
  43. package/dist/cjs/foundation/foundationStrings.js +27 -1
  44. package/dist/cjs/foundation/foundationStrings.js.map +1 -1
  45. package/dist/cjs/foundation/orgModel.js +106 -0
  46. package/dist/cjs/foundation/orgModel.js.map +1 -0
  47. package/dist/cjs/foundation/permissionModel.js +84 -0
  48. package/dist/cjs/foundation/permissionModel.js.map +1 -0
  49. package/dist/cjs/foundation/uploadModel.js +71 -0
  50. package/dist/cjs/foundation/uploadModel.js.map +1 -0
  51. package/dist/cjs/index.js +59 -0
  52. package/dist/cjs/index.js.map +1 -1
  53. package/dist/esm/ComponentName.d.ts +8 -1
  54. package/dist/esm/ComponentName.d.ts.map +1 -1
  55. package/dist/esm/ComponentName.js +7 -0
  56. package/dist/esm/ComponentName.js.map +1 -1
  57. package/dist/esm/defaultBlueprint.js +2 -0
  58. package/dist/esm/defaultBlueprint.js.map +1 -1
  59. package/dist/esm/defaultBlueprintComponentMetadata.js +2 -0
  60. package/dist/esm/defaultBlueprintComponentMetadata.js.map +1 -1
  61. package/dist/esm/defaultBlueprintComponents.d.ts.map +1 -1
  62. package/dist/esm/defaultBlueprintComponents.js +15 -1
  63. package/dist/esm/defaultBlueprintComponents.js.map +1 -1
  64. package/dist/esm/foundation/ActivityFeed.d.ts +3 -0
  65. package/dist/esm/foundation/ActivityFeed.d.ts.map +1 -0
  66. package/dist/esm/foundation/ActivityFeed.js +120 -0
  67. package/dist/esm/foundation/ActivityFeed.js.map +1 -0
  68. package/dist/esm/foundation/ApprovalChainStepper.d.ts +3 -0
  69. package/dist/esm/foundation/ApprovalChainStepper.d.ts.map +1 -0
  70. package/dist/esm/foundation/ApprovalChainStepper.js +128 -0
  71. package/dist/esm/foundation/ApprovalChainStepper.js.map +1 -0
  72. package/dist/esm/foundation/AvatarStack.d.ts +3 -0
  73. package/dist/esm/foundation/AvatarStack.d.ts.map +1 -0
  74. package/dist/esm/foundation/AvatarStack.js +71 -0
  75. package/dist/esm/foundation/AvatarStack.js.map +1 -0
  76. package/dist/esm/foundation/ChecklistSection.d.ts +3 -0
  77. package/dist/esm/foundation/ChecklistSection.d.ts.map +1 -0
  78. package/dist/esm/foundation/ChecklistSection.js +172 -0
  79. package/dist/esm/foundation/ChecklistSection.js.map +1 -0
  80. package/dist/esm/foundation/FileUploader.d.ts +3 -0
  81. package/dist/esm/foundation/FileUploader.d.ts.map +1 -0
  82. package/dist/esm/foundation/FileUploader.js +216 -0
  83. package/dist/esm/foundation/FileUploader.js.map +1 -0
  84. package/dist/esm/foundation/FileUploaderHandler.d.ts +8 -0
  85. package/dist/esm/foundation/FileUploaderHandler.d.ts.map +1 -0
  86. package/dist/esm/foundation/FileUploaderHandler.js +14 -0
  87. package/dist/esm/foundation/FileUploaderHandler.js.map +1 -0
  88. package/dist/esm/foundation/Foundation.stories.d.ts +16 -0
  89. package/dist/esm/foundation/Foundation.stories.d.ts.map +1 -1
  90. package/dist/esm/foundation/Foundation.stories.js +16 -0
  91. package/dist/esm/foundation/Foundation.stories.js.map +1 -1
  92. package/dist/esm/foundation/OrgTree.d.ts +3 -0
  93. package/dist/esm/foundation/OrgTree.d.ts.map +1 -0
  94. package/dist/esm/foundation/OrgTree.js +186 -0
  95. package/dist/esm/foundation/OrgTree.js.map +1 -0
  96. package/dist/esm/foundation/PermissionMatrix.d.ts +3 -0
  97. package/dist/esm/foundation/PermissionMatrix.d.ts.map +1 -0
  98. package/dist/esm/foundation/PermissionMatrix.js +134 -0
  99. package/dist/esm/foundation/PermissionMatrix.js.map +1 -0
  100. package/dist/esm/foundation/activityModel.d.ts +22 -0
  101. package/dist/esm/foundation/activityModel.d.ts.map +1 -0
  102. package/dist/esm/foundation/activityModel.js +58 -0
  103. package/dist/esm/foundation/activityModel.js.map +1 -0
  104. package/dist/esm/foundation/approvalModel.d.ts +27 -0
  105. package/dist/esm/foundation/approvalModel.d.ts.map +1 -0
  106. package/dist/esm/foundation/approvalModel.js +81 -0
  107. package/dist/esm/foundation/approvalModel.js.map +1 -0
  108. package/dist/esm/foundation/avatarModel.d.ts +11 -0
  109. package/dist/esm/foundation/avatarModel.d.ts.map +1 -0
  110. package/dist/esm/foundation/avatarModel.js +38 -0
  111. package/dist/esm/foundation/avatarModel.js.map +1 -0
  112. package/dist/esm/foundation/checklistModel.d.ts +17 -0
  113. package/dist/esm/foundation/checklistModel.d.ts.map +1 -0
  114. package/dist/esm/foundation/checklistModel.js +53 -0
  115. package/dist/esm/foundation/checklistModel.js.map +1 -0
  116. package/dist/esm/foundation/formatting.d.ts +1 -0
  117. package/dist/esm/foundation/formatting.d.ts.map +1 -1
  118. package/dist/esm/foundation/formatting.js +11 -1
  119. package/dist/esm/foundation/formatting.js.map +1 -1
  120. package/dist/esm/foundation/foundationComponentMetadata.d.ts.map +1 -1
  121. package/dist/esm/foundation/foundationComponentMetadata.js +47 -0
  122. package/dist/esm/foundation/foundationComponentMetadata.js.map +1 -1
  123. package/dist/esm/foundation/foundationDescriptors.d.ts.map +1 -1
  124. package/dist/esm/foundation/foundationDescriptors.js +246 -0
  125. package/dist/esm/foundation/foundationDescriptors.js.map +1 -1
  126. package/dist/esm/foundation/foundationSamples.d.ts.map +1 -1
  127. package/dist/esm/foundation/foundationSamples.js +230 -0
  128. package/dist/esm/foundation/foundationSamples.js.map +1 -1
  129. package/dist/esm/foundation/foundationStrings.d.ts +26 -0
  130. package/dist/esm/foundation/foundationStrings.d.ts.map +1 -1
  131. package/dist/esm/foundation/foundationStrings.js +27 -1
  132. package/dist/esm/foundation/foundationStrings.js.map +1 -1
  133. package/dist/esm/foundation/index.d.ts +15 -0
  134. package/dist/esm/foundation/index.d.ts.map +1 -1
  135. package/dist/esm/foundation/index.js +15 -0
  136. package/dist/esm/foundation/index.js.map +1 -1
  137. package/dist/esm/foundation/orgModel.d.ts +20 -0
  138. package/dist/esm/foundation/orgModel.d.ts.map +1 -0
  139. package/dist/esm/foundation/orgModel.js +99 -0
  140. package/dist/esm/foundation/orgModel.js.map +1 -0
  141. package/dist/esm/foundation/permissionModel.d.ts +24 -0
  142. package/dist/esm/foundation/permissionModel.d.ts.map +1 -0
  143. package/dist/esm/foundation/permissionModel.js +77 -0
  144. package/dist/esm/foundation/permissionModel.js.map +1 -0
  145. package/dist/esm/foundation/uploadModel.d.ts +31 -0
  146. package/dist/esm/foundation/uploadModel.d.ts.map +1 -0
  147. package/dist/esm/foundation/uploadModel.js +65 -0
  148. package/dist/esm/foundation/uploadModel.js.map +1 -0
  149. package/dist/esm/index.js +16 -1
  150. package/dist/esm/index.js.map +1 -1
  151. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  152. package/foundation/ActivityFeed.tsx +61 -0
  153. package/foundation/ApprovalChainStepper.tsx +66 -0
  154. package/foundation/AvatarStack.tsx +43 -0
  155. package/foundation/ChecklistSection.tsx +80 -0
  156. package/foundation/FileUploader.tsx +122 -0
  157. package/foundation/FileUploaderHandler.tsx +22 -0
  158. package/foundation/Foundation.stories.tsx +16 -0
  159. package/foundation/OrgTree.tsx +101 -0
  160. package/foundation/PermissionMatrix.tsx +82 -0
  161. package/foundation/activityModel.ts +65 -0
  162. package/foundation/approvalModel.ts +88 -0
  163. package/foundation/avatarModel.ts +36 -0
  164. package/foundation/checklistModel.ts +62 -0
  165. package/foundation/formatting.ts +9 -0
  166. package/foundation/foundationComponentMetadata.ts +7 -0
  167. package/foundation/foundationDescriptors.ts +153 -0
  168. package/foundation/foundationSamples.ts +59 -0
  169. package/foundation/foundationStrings.ts +26 -0
  170. package/foundation/index.ts +15 -0
  171. package/foundation/orgModel.ts +85 -0
  172. package/foundation/permissionModel.ts +85 -0
  173. package/foundation/uploadModel.ts +66 -0
  174. package/package.json +8 -8
  175. package/shell/layout.css +418 -0
@@ -0,0 +1,61 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import { useMemo } from 'react';
5
+ import { RegisteredComponentProps } from '@cratis/scene.react';
6
+ import { readOptionalString } from '../shell/elementProperties';
7
+ import { ActivityKind, groupByDay, readActivity } from './activityModel';
8
+ import { ComponentState, readComponentState } from './ComponentState';
9
+ import { FoundationIcon, FoundationIconName } from './FoundationIcon';
10
+ import { formatDate, formatDay, readLocale } from './formatting';
11
+ import { readFoundationStrings } from './foundationStrings';
12
+ import { FoundationFrame } from './StateFrame';
13
+
14
+ const icons: Record<ActivityKind, FoundationIconName> = {
15
+ [ActivityKind.Comment]: 'user', [ActivityKind.Change]: 'refresh', [ActivityKind.Status]: 'check', [ActivityKind.System]: 'info',
16
+ };
17
+
18
+ /**
19
+ * The `activityFeed` component: what happened to a record, newest first, under a heading for each day.
20
+ *
21
+ * `entries` carry an id, who (`actor`), what (`action`), the instant (`at`, ISO 8601), a `kind` and an optional `body` such as a comment's
22
+ * text. The feed is an ordered list; each entry names its kind in words for assistive technology as well as by icon, and its time is a
23
+ * `time` element formatted for the locale. Content is supplied by the application - this only lays it out - and a feed of any length
24
+ * stays one list. `order="ascending"` reads oldest first, and `timeZone` fixes the zone days are cut in.
25
+ */
26
+ export function ActivityFeed({ element, interactions }: RegisteredComponentProps) {
27
+ const entries = useMemo(() => readActivity(element.properties.entries), [element.properties.entries]);
28
+ const strings = readFoundationStrings(element);
29
+ const locale = readLocale(element);
30
+ const timeZone = readOptionalString(element, 'timeZone');
31
+ const days = useMemo(() => groupByDay(entries, element.properties.order === 'ascending', timeZone), [entries, element.properties.order, timeZone]);
32
+ const state = readComponentState(element, entries.length > 0);
33
+ const kindText: Record<ActivityKind, string> = { comment: strings.activityComment, change: strings.activityChange, status: strings.activityStatus, system: strings.activitySystem };
34
+ const emptyElement = state === ComponentState.Empty ? { ...element, properties: { emptyHeadline: strings.activityEmpty, ...element.properties } } : element;
35
+
36
+ return (
37
+ <FoundationFrame element={emptyElement} interactions={interactions} name='activity-feed' state={state} skeleton={[60, 90, 70, 85]}>
38
+ {(state === ComponentState.Ready || state === ComponentState.Partial) && days.map(day => (
39
+ <section key={day.day} className='foundation-activity-day'>
40
+ <h3 className='foundation-activity-heading'><time dateTime={day.day}>{formatDay(day.day, locale)}</time></h3>
41
+ <ol className='foundation-activity-list'>
42
+ {day.entries.map(entry => (
43
+ <li key={entry.id} className='foundation-activity-entry' data-kind={entry.kind}>
44
+ <span className='foundation-activity-icon'><FoundationIcon name={icons[entry.kind]} /></span>
45
+ <div className='foundation-activity-content'>
46
+ <p className='foundation-activity-summary'>
47
+ <span className='foundation-visually-hidden'>{`${kindText[entry.kind]}: `}</span>
48
+ <strong>{entry.actor}</strong> {entry.action}
49
+ </p>
50
+ {entry.body && <p className='foundation-activity-body'>{entry.body}</p>}
51
+ <time className='foundation-count' dateTime={entry.at}>{formatDate(entry.at, locale, true)}</time>
52
+ </div>
53
+ </li>
54
+ ))}
55
+ </ol>
56
+ </section>
57
+ ))}
58
+ </FoundationFrame>
59
+ );
60
+ }
61
+
@@ -0,0 +1,66 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import { useMemo } from 'react';
5
+ import { RegisteredComponentProps } from '@cratis/scene.react';
6
+ import { ApprovalState, ChainOutcome, chainOutcome, effectiveSteps, readSteps } from './approvalModel';
7
+ import { ComponentState, readComponentState } from './ComponentState';
8
+ import { FoundationIcon, FoundationIconName } from './FoundationIcon';
9
+ import { formatDate, readLocale } from './formatting';
10
+ import { formatText, readFoundationStrings } from './foundationStrings';
11
+ import { FoundationFrame } from './StateFrame';
12
+
13
+ const icons: Record<ApprovalState, FoundationIconName> = {
14
+ [ApprovalState.Pending]: 'circle', [ApprovalState.Current]: 'clock', [ApprovalState.Approved]: 'check', [ApprovalState.Rejected]: 'close', [ApprovalState.Skipped]: 'minus',
15
+ };
16
+ const tones: Record<ApprovalState, string> = {
17
+ [ApprovalState.Pending]: 'neutral', [ApprovalState.Current]: 'info', [ApprovalState.Approved]: 'success', [ApprovalState.Rejected]: 'danger', [ApprovalState.Skipped]: 'neutral',
18
+ };
19
+
20
+ /**
21
+ * The `approvalChainStepper` component: the steps a request goes through, who decides each, and where it stands.
22
+ *
23
+ * `steps` are written in order with a `label`, an `approver`, a `state` (pending, current, approved, rejected, skipped), and when it was
24
+ * decided (`at`) and a `comment`. The chain applies its own rules - a rejection stops the chain and the steps after it read "Not reached", and
25
+ * with no current step the first undecided one is it - so a request page needs no logic of its own. Every step says its state in words as
26
+ * well as by icon and color, the current step carries `aria-current="step"`, and the whole is an ordered list with the overall outcome
27
+ * stated above it. `orientation` is `horizontal` or `vertical`; below a phone's width it is always vertical.
28
+ */
29
+ export function ApprovalChainStepper({ element, interactions }: RegisteredComponentProps) {
30
+ const written = useMemo(() => readSteps(element.properties.steps), [element.properties.steps]);
31
+ const steps = useMemo(() => effectiveSteps(written), [written]);
32
+ const outcome = chainOutcome(written);
33
+ const strings = readFoundationStrings(element);
34
+ const locale = readLocale(element);
35
+ const state = readComponentState(element, written.length > 0);
36
+ const stateText: Record<ApprovalState, string> = {
37
+ pending: strings.approvalPending, current: strings.approvalCurrent, approved: strings.approvalApproved, rejected: strings.approvalRejected, skipped: strings.approvalSkipped,
38
+ };
39
+ const outcomeText: Record<ChainOutcome, string> = {
40
+ approved: strings.approvalOutcomeApproved, rejected: strings.approvalOutcomeRejected, inProgress: strings.approvalOutcomeInProgress, pending: strings.approvalOutcomePending,
41
+ };
42
+
43
+ return (
44
+ <FoundationFrame element={element} interactions={interactions} name='approval-chain' state={state} skeleton={[100, 100]}>
45
+ {(state === ComponentState.Ready || state === ComponentState.Partial) && (
46
+ <>
47
+ <p className='foundation-approval-outcome' data-outcome={outcome}>{outcomeText[outcome]}</p>
48
+ <ol className='foundation-approval-steps' aria-label={strings.approvalChain} data-orientation={element.properties.orientation === 'vertical' ? 'vertical' : 'horizontal'}>
49
+ {steps.map((step, index) => (
50
+ <li key={step.label + index} className='foundation-approval-step' data-state={step.state} aria-current={step.state === ApprovalState.Current ? 'step' : undefined}>
51
+ <span className='foundation-chip' data-tone={tones[step.state]}><FoundationIcon name={icons[step.state]} /><span>{step.notReached ? strings.approvalNotReached : stateText[step.state]}</span></span>
52
+ <strong className='foundation-approval-label'>
53
+ <span className='foundation-visually-hidden'>{`${formatText(strings.approvalStep, { n: index + 1, total: steps.length })}: `}</span>
54
+ {step.label}
55
+ </strong>
56
+ {step.approver && <span className='foundation-count'>{step.approver}</span>}
57
+ {step.at && <time className='foundation-count' dateTime={step.at}>{formatDate(step.at, locale, true)}</time>}
58
+ {step.comment && <span className='foundation-approval-comment'>{step.comment}</span>}
59
+ </li>
60
+ ))}
61
+ </ol>
62
+ </>
63
+ )}
64
+ </FoundationFrame>
65
+ );
66
+ }
@@ -0,0 +1,43 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import { useMemo } from 'react';
5
+ import { RegisteredComponentProps } from '@cratis/scene.react';
6
+ import { initialsOf, readPeople, visiblePeople } from './avatarModel';
7
+ import { ComponentState, readComponentState } from './ComponentState';
8
+ import { formatText, readFoundationStrings } from './foundationStrings';
9
+ import { FoundationFrame } from './StateFrame';
10
+
11
+ /**
12
+ * The `avatarStack` component: overlapping avatars for the people on a record, with a "+N" chip for the rest.
13
+ *
14
+ * `people` are names with an optional picture; without one the avatar shows the person's initials. The stack is a labelled group ("5 people"),
15
+ * each avatar is an image named after its person, and the overflow chip says how many more in words. `max` is how many are shown (4 by
16
+ * default) and `size` is `small` or `medium`.
17
+ */
18
+ export function AvatarStack({ element, interactions }: RegisteredComponentProps) {
19
+ const people = useMemo(() => readPeople(element.properties.people), [element.properties.people]);
20
+ const strings = readFoundationStrings(element);
21
+ const { shown, hidden } = visiblePeople(people, typeof element.properties.max === 'number' ? element.properties.max : 4);
22
+ const state = readComponentState(element, people.length > 0);
23
+ const size = element.properties.size === 'small' ? 'small' : 'medium';
24
+
25
+ return (
26
+ <FoundationFrame element={element} interactions={interactions} name='avatar-stack' state={state} skeleton={[35]}>
27
+ {(state === ComponentState.Ready || state === ComponentState.Partial) && (
28
+ <ul className='foundation-avatars' role='group' aria-label={formatText(strings.avatarPeople, { count: people.length })} data-size={size}>
29
+ {shown.map(person => (
30
+ <li key={person.name} className='foundation-avatar' role='img' aria-label={person.name} title={person.name}>
31
+ {person.imageUrl ? <img src={person.imageUrl} alt='' /> : <span aria-hidden='true'>{initialsOf(person.name)}</span>}
32
+ </li>
33
+ ))}
34
+ {hidden > 0 && (
35
+ <li className='foundation-avatar foundation-avatar-more' role='img' aria-label={formatText(strings.avatarOverflow, { count: hidden })}>
36
+ <span aria-hidden='true'>+{hidden}</span>
37
+ </li>
38
+ )}
39
+ </ul>
40
+ )}
41
+ </FoundationFrame>
42
+ );
43
+ }
@@ -0,0 +1,80 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import { useEffect, useMemo, useState } from 'react';
5
+ import { RegisteredComponentProps, useValueOutput } from '@cratis/scene.react';
6
+ import { ChecklistGroup, completed, doneTaskIds, isOverdue, readChecklist, toggleTask } from './checklistModel';
7
+ import { ComponentState, readComponentState } from './ComponentState';
8
+ import { FoundationIcon } from './FoundationIcon';
9
+ import { formatDate, readLocale } from './formatting';
10
+ import { formatText, readFoundationStrings } from './foundationStrings';
11
+ import { readPermissionRule } from './PermissionRule';
12
+ import { FoundationFrame } from './StateFrame';
13
+
14
+ /**
15
+ * The `checklistSection` component: sections of tasks, each with its owner and due date, and a progress bar per section.
16
+ *
17
+ * `sections` give the structure (`title`, and `items` with `id`, `label`, `owner`, `due`, `done`). Ticking a task publishes the ids of all
18
+ * done tasks as the `value` output and runs the behaviors attached to the element. An open task past its due date says "Overdue" in
19
+ * words as well as in color. Progress is a labelled progress bar, so "2 of 5 complete" is read, not just seen. With `editPermission`
20
+ * not enabled the tasks are shown and cannot be changed.
21
+ */
22
+ export function ChecklistSection({ element, interactions, bindingOutputs }: RegisteredComponentProps) {
23
+ const supplied = useMemo(() => readChecklist(element.properties.sections), [element.properties.sections]);
24
+ const suppliedKey = JSON.stringify(supplied);
25
+ const [groups, setGroups] = useState<ChecklistGroup[]>(supplied);
26
+ const strings = readFoundationStrings(element);
27
+ const locale = readLocale(element);
28
+ const state = readComponentState(element, supplied.some(group => group.items.length > 0));
29
+ const locked = readPermissionRule(element, 'editPermission') !== 'enabled';
30
+ const now = useMemo(() => (typeof element.properties.today === 'string' ? new Date(element.properties.today) : new Date()), [element.properties.today]);
31
+
32
+ useEffect(() => setGroups(supplied), [suppliedKey]);
33
+ useValueOutput(bindingOutputs, doneTaskIds(groups));
34
+
35
+ return (
36
+ <FoundationFrame element={element} interactions={interactions} name='checklist' state={state} skeleton={[40, 100, 90, 95]}>
37
+ {(state === ComponentState.Ready || state === ComponentState.Partial) && groups.map((group, groupIndex) => {
38
+ const done = completed(group);
39
+ const total = group.items.length;
40
+ return (
41
+ <section key={group.title + groupIndex} className='foundation-checklist-group' aria-labelledby={`${element.id}-group-${groupIndex}`}>
42
+ <header className='foundation-checklist-header'>
43
+ <h3 id={`${element.id}-group-${groupIndex}`}>{group.title}</h3>
44
+ <span className='foundation-count'>{formatText(strings.checklistProgress, { done, total })}</span>
45
+ </header>
46
+ <div className='foundation-progress' role='progressbar' aria-label={formatText(strings.checklistItemProgress, { title: group.title })}
47
+ aria-valuemin={0} aria-valuemax={total} aria-valuenow={done} aria-valuetext={formatText(strings.checklistProgress, { done, total })}>
48
+ <span className='foundation-progress-bar' style={{ inlineSize: total === 0 ? '0%' : `${(done / total) * 100}%` }} />
49
+ </div>
50
+ {total === 0 ? <p className='foundation-count'>{strings.checklistNone}</p> : (
51
+ <ul className='foundation-checklist-items'>
52
+ {group.items.map(item => {
53
+ const overdue = isOverdue(item, now);
54
+ return (
55
+ <li key={item.id} className='foundation-checklist-item' data-done={item.done} data-overdue={overdue}>
56
+ <label className='foundation-check'>
57
+ <input type='checkbox' checked={item.done} disabled={locked} onChange={() => { setGroups(toggleTask(groups, item.id)); interactions?.onChange?.(); }} />
58
+ <span className='foundation-checklist-label'>{item.label}</span>
59
+ </label>
60
+ <span className='foundation-checklist-meta'>
61
+ {item.owner && <span><span className='foundation-visually-hidden'>{`${strings.checklistOwner}: `}</span><FoundationIcon name='user' /> {item.owner}</span>}
62
+ {item.due && (
63
+ <span>
64
+ <span className='foundation-visually-hidden'>{`${strings.checklistDue}: `}</span>
65
+ <time dateTime={item.due}>{formatDate(item.due, locale)}</time>
66
+ {overdue && <strong className='foundation-overdue'> · <FoundationIcon name='alert' /> {strings.checklistOverdue}</strong>}
67
+ </span>
68
+ )}
69
+ </span>
70
+ </li>
71
+ );
72
+ })}
73
+ </ul>
74
+ )}
75
+ </section>
76
+ );
77
+ })}
78
+ </FoundationFrame>
79
+ );
80
+ }
@@ -0,0 +1,122 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import { ChangeEvent, DragEvent, useRef, useState } from 'react';
5
+ import { RegisteredComponentProps, useValueOutput } from '@cratis/scene.react';
6
+ import { readOptionalString } from '../shell/elementProperties';
7
+ import { ActionButton } from './ActionButton';
8
+ import { ComponentState, readComponentState } from './ComponentState';
9
+ import { useFileUploaderHandler } from './FileUploaderHandler';
10
+ import { FoundationIcon } from './FoundationIcon';
11
+ import { readLocale } from './formatting';
12
+ import { formatText, readFoundationStrings } from './foundationStrings';
13
+ import { readPermissionRule } from './PermissionRule';
14
+ import { FoundationFrame } from './StateFrame';
15
+ import { UploadRefusal, UploadStatus, checkSelection, formatBytes } from './uploadModel';
16
+
17
+ interface Entry {
18
+ file: File;
19
+ status: UploadStatus;
20
+ }
21
+
22
+ /**
23
+ * The `fileUploader` component: a drop zone and a button to choose files, checked against type and size before anything is sent.
24
+ *
25
+ * `accept` is an `accept` list, `maxSizeMb` the largest file, and `multiple` whether more than one is allowed. The zone is a real file
26
+ * input behind a labelled button, so it works from the keyboard and with a screen reader; dropping onto it does the same as choosing.
27
+ * Refusals are announced with their reason. Accepted files are listed with a remove button and a status in words; the selection
28
+ * (name, size, type) is published as the `value` output. With a `FileUploaderHandlerProvider` in scope the files are sent through it and
29
+ * marked uploading, done or failed; without one the component only collects. `actionPermission` gates choosing files.
30
+ */
31
+ export function FileUploader({ element, interactions, bindingOutputs }: RegisteredComponentProps) {
32
+ const strings = readFoundationStrings(element);
33
+ const locale = readLocale(element);
34
+ const handler = useFileUploaderHandler();
35
+ const [entries, setEntries] = useState<Entry[]>([]);
36
+ const [problems, setProblems] = useState<string[]>([]);
37
+ const [dragging, setDragging] = useState(false);
38
+ const input = useRef<HTMLInputElement>(null);
39
+ const accept = readOptionalString(element, 'accept') ?? '';
40
+ const maximumBytes = (typeof element.properties.maxSizeMb === 'number' ? element.properties.maxSizeMb : 0) * 1024 * 1024;
41
+ const multiple = element.properties.multiple !== false;
42
+ const rule = readPermissionRule(element, 'actionPermission');
43
+ const state = readComponentState(element, true);
44
+ const serverUrl = readOptionalString(element, 'serverUrl');
45
+
46
+ useValueOutput(bindingOutputs, entries.map(entry => ({ name: entry.file.name, size: entry.file.size, type: entry.file.type, status: entry.status })));
47
+
48
+ const send = async (files: File[]) => {
49
+ if (!handler) return;
50
+ const mark = (status: UploadStatus) => setEntries(current => current.map(entry => (files.includes(entry.file) ? { ...entry, status } : entry)));
51
+ mark(UploadStatus.Uploading);
52
+ try {
53
+ await handler(files, serverUrl);
54
+ mark(UploadStatus.Done);
55
+ } catch {
56
+ mark(UploadStatus.Failed);
57
+ }
58
+ };
59
+
60
+ const take = (chosen: File[]) => {
61
+ const { accepted, refused } = checkSelection(chosen, { accept, maximumBytes, multiple }, entries.length);
62
+ setProblems(refused.map(({ file, reason }) => reason === UploadRefusal.WrongType ? formatText(strings.uploadWrongType, { name: file.name })
63
+ : reason === UploadRefusal.TooLarge ? formatText(strings.uploadTooLarge, { name: file.name, limit: formatBytes(maximumBytes, locale) }) : strings.uploadSingle));
64
+ if (accepted.length === 0) return;
65
+
66
+ setEntries(current => [...current, ...accepted.map(file => ({ file, status: UploadStatus.Queued }))]);
67
+ interactions?.onChange?.();
68
+ void send(accepted);
69
+ };
70
+
71
+ const choose = (event: ChangeEvent<HTMLInputElement>) => {
72
+ take(Array.from(event.target.files ?? []));
73
+ event.target.value = '';
74
+ };
75
+ const drop = (event: DragEvent) => {
76
+ event.preventDefault();
77
+ setDragging(false);
78
+ if (rule === 'enabled') take(Array.from(event.dataTransfer.files));
79
+ };
80
+ const statusText: Record<UploadStatus, string> = {
81
+ queued: strings.uploadQueued, uploading: strings.uploadUploading, done: strings.uploadDone, failed: strings.uploadFailed,
82
+ };
83
+ const limits = [accept ? formatText(strings.uploadAccepts, { types: accept }) : '', maximumBytes > 0 ? formatText(strings.uploadLimits, { limit: formatBytes(maximumBytes, locale) }) : ''].filter(Boolean).join(' · ');
84
+
85
+ return (
86
+ <FoundationFrame element={element} interactions={interactions} name='file-uploader' state={state} skeleton={[100, 40]}>
87
+ {state !== ComponentState.Loading && (
88
+ <>
89
+ <div className='foundation-dropzone' data-dragging={dragging} onDragOver={event => { event.preventDefault(); setDragging(true); }} onDragLeave={() => setDragging(false)} onDrop={drop}>
90
+ <FoundationIcon name='upload' />
91
+ <span>{strings.uploadDrop}</span>
92
+ <input ref={input} className='foundation-visually-hidden' type='file' tabIndex={-1} accept={accept || undefined} multiple={multiple} disabled={rule !== 'enabled'} onChange={choose} aria-hidden='true' />
93
+ <ActionButton rule={rule} emphasis='secondary' disabledReason={strings.disabledNoPermission} onClick={() => input.current?.click()}>
94
+ {strings.uploadBrowse}
95
+ </ActionButton>
96
+ {limits && <span className='foundation-count'>{limits}</span>}
97
+ </div>
98
+ <div aria-live='polite' className='foundation-upload-problems'>
99
+ {problems.map(problem => <p key={problem} className='foundation-message-text' data-tone='danger'>{problem}</p>)}
100
+ </div>
101
+ {entries.length > 0 && (
102
+ <ul className='foundation-upload-list' aria-label={strings.uploadSelected}>
103
+ {entries.map((entry, index) => (
104
+ <li key={entry.file.name + index} className='foundation-upload-item' data-status={entry.status}>
105
+ <FoundationIcon name='file' />
106
+ <span className='foundation-upload-name'>{entry.file.name}</span>
107
+ <span className='foundation-count'>{formatBytes(entry.file.size, locale)}</span>
108
+ <span className='foundation-upload-status'>{statusText[entry.status]}</span>
109
+ {entry.status === UploadStatus.Failed && <button type='button' className='foundation-link' onClick={() => void send([entry.file])}>{strings.retry}</button>}
110
+ <button type='button' className='foundation-icon-button' aria-label={formatText(strings.uploadRemove, { name: entry.file.name })}
111
+ onClick={() => { setEntries(current => current.filter(existing => existing !== entry)); interactions?.onChange?.(); }}>
112
+ <FoundationIcon name='close' />
113
+ </button>
114
+ </li>
115
+ ))}
116
+ </ul>
117
+ )}
118
+ </>
119
+ )}
120
+ </FoundationFrame>
121
+ );
122
+ }
@@ -0,0 +1,22 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import { ReactNode, createContext, useContext } from 'react';
5
+
6
+ /**
7
+ * What sends the accepted files somewhere: a host's server client, kept out of the portable package.
8
+ *
9
+ * The signature is the one `@cratis/scene.primereact` uses for its `fileUpload`, so a host hands the same function to both. A handler that
10
+ * rejects marks the files as failed, with a retry; without a handler the uploader only collects and publishes the selection.
11
+ */
12
+ export type FileUploaderHandler = (files: File[], serverUrl?: string) => Promise<void>;
13
+
14
+ const Context = createContext<FileUploaderHandler | undefined>(undefined);
15
+
16
+ /** Provides the upload handler to the file uploaders below it. */
17
+ export function FileUploaderHandlerProvider({ handler, children }: { handler?: FileUploaderHandler; children: ReactNode }) {
18
+ return <Context.Provider value={handler}>{children}</Context.Provider>;
19
+ }
20
+
21
+ /** The handler in scope, if any. */
22
+ export const useFileUploaderHandler = (): FileUploaderHandler | undefined => useContext(Context);
@@ -44,3 +44,19 @@ export const ColumnChooserLight: Story = { args: { component: ComponentName.Colu
44
44
  export const ColumnChooserDark: Story = { args: { component: ComponentName.ColumnChooser, theme: 'dark' } };
45
45
  export const SavedViewsMenuLight: Story = { args: { component: ComponentName.SavedViewsMenu, theme: 'light' } };
46
46
  export const SavedViewsMenuDark: Story = { args: { component: ComponentName.SavedViewsMenu, theme: 'dark' } };
47
+ export const ChecklistLight: Story = { args: { component: ComponentName.ChecklistSection, theme: 'light' } };
48
+ export const ChecklistDark: Story = { args: { component: ComponentName.ChecklistSection, theme: 'dark' } };
49
+ export const ActivityFeedLight: Story = { args: { component: ComponentName.ActivityFeed, theme: 'light' } };
50
+ export const ActivityFeedDark: Story = { args: { component: ComponentName.ActivityFeed, theme: 'dark' } };
51
+ export const AvatarStackLight: Story = { args: { component: ComponentName.AvatarStack, theme: 'light' } };
52
+ export const AvatarStackDark: Story = { args: { component: ComponentName.AvatarStack, theme: 'dark' } };
53
+ export const FileUploaderLight: Story = { args: { component: ComponentName.FileUploader, theme: 'light' } };
54
+ export const FileUploaderDark: Story = { args: { component: ComponentName.FileUploader, theme: 'dark' } };
55
+ export const ApprovalChainLight: Story = { args: { component: ComponentName.ApprovalChainStepper, theme: 'light' } };
56
+ export const ApprovalChainDark: Story = { args: { component: ComponentName.ApprovalChainStepper, theme: 'dark' } };
57
+ export const OrgTreeLight: Story = { args: { component: ComponentName.OrgTree, theme: 'light' } };
58
+ export const OrgTreeDark: Story = { args: { component: ComponentName.OrgTree, theme: 'dark' } };
59
+ export const PermissionMatrixLight: Story = { args: { component: ComponentName.PermissionMatrix, theme: 'light' } };
60
+ export const PermissionMatrixDark: Story = { args: { component: ComponentName.PermissionMatrix, theme: 'dark' } };
61
+ export const ApprovalChainRejectedLight: Story = { args: { component: ComponentName.ApprovalChainStepper, theme: 'light', properties: { steps: [{ label: 'Team lead', state: 'approved' }, { label: 'Finance', state: 'rejected', comment: 'Over budget.' }, { label: 'Director' }] } } };
62
+ export const PermissionMatrixReadOnlyDark: Story = { args: { component: ComponentName.PermissionMatrix, theme: 'dark', properties: { editPermission: 'disabled' } } };
@@ -0,0 +1,101 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import { KeyboardEvent, useEffect, useMemo, useState } from 'react';
5
+ import { RegisteredComponentProps, useValueOutput } from '@cratis/scene.react';
6
+ import { ComponentState, readComponentState } from './ComponentState';
7
+ import { FoundationIcon } from './FoundationIcon';
8
+ import { formatText, readFoundationStrings } from './foundationStrings';
9
+ import { descendantCount, expandedToDepth, OrgTreeNode, buildOrgTree, placements, readOrgNodes, visibleNodes } from './orgModel';
10
+ import { FoundationFrame } from './StateFrame';
11
+
12
+ /**
13
+ * The `orgTree` component: an organization as an expandable tree of people and units.
14
+ *
15
+ * `nodes` are flat rows - `id`, `label`, `title`, `parentId` - as a query returns them; the tree is built from them, so someone whose manager is
16
+ * missing becomes a root and a reporting cycle is cut instead of looping. It follows the ARIA tree pattern: one tab stop, Up and Down move,
17
+ * Right expands or enters, Left collapses or goes to the parent, Home and End jump, Enter or Space selects. Each item says how many reports it
18
+ * has in words. The selected id is published as the `value` output; `expandedDepth` is how many levels are open at first.
19
+ */
20
+ export function OrgTree({ element, interactions, bindingOutputs }: RegisteredComponentProps) {
21
+ const nodes = useMemo(() => readOrgNodes(element.properties.nodes), [element.properties.nodes]);
22
+ const roots = useMemo(() => buildOrgTree(nodes), [nodes]);
23
+ const depth = typeof element.properties.expandedDepth === 'number' ? element.properties.expandedDepth : 2;
24
+ const initial = useMemo(() => expandedToDepth(roots, depth), [roots, depth]);
25
+ const [expanded, setExpanded] = useState<Set<string>>(initial);
26
+ const [selected, setSelected] = useState<string | undefined>(typeof element.properties.selected === 'string' ? element.properties.selected : undefined);
27
+ const [focused, setFocused] = useState<string | undefined>(undefined);
28
+ const strings = readFoundationStrings(element);
29
+ const state = readComponentState(element, nodes.length > 0);
30
+ const visible = visibleNodes(roots, expanded);
31
+ const placed = useMemo(() => placements(roots), [roots]);
32
+ const tabStop = visible.find(node => node.id === focused)?.id ?? visible.find(node => node.id === selected)?.id ?? visible[0]?.id;
33
+ const emptyElement = state === ComponentState.Empty ? { ...element, properties: { emptyHeadline: strings.orgNone, ...element.properties } } : element;
34
+
35
+ useEffect(() => setExpanded(initial), [initial]);
36
+ useValueOutput(bindingOutputs, selected);
37
+
38
+ const move = (node: OrgTreeNode, event: KeyboardEvent) => {
39
+ const index = visible.indexOf(node);
40
+ const focus = (target: OrgTreeNode | undefined) => {
41
+ if (!target) return;
42
+ event.preventDefault();
43
+ setFocused(target.id);
44
+ requestAnimationFrame(() => document.getElementById(`${element.id}-${target.id}`)?.focus());
45
+ };
46
+ const toggle = (open: boolean) => setExpanded(current => { const next = new Set(current); if (open) next.add(node.id); else next.delete(node.id); return next; });
47
+ const parent = placed.get(node.id)?.parent;
48
+
49
+ switch (event.key) {
50
+ case 'ArrowDown': focus(visible[index + 1]); break;
51
+ case 'ArrowUp': focus(visible[index - 1]); break;
52
+ case 'Home': focus(visible[0]); break;
53
+ case 'End': focus(visible[visible.length - 1]); break;
54
+ case 'ArrowRight':
55
+ if (node.children.length > 0 && !expanded.has(node.id)) { event.preventDefault(); toggle(true); } else focus(node.children[0]);
56
+ break;
57
+ case 'ArrowLeft':
58
+ if (node.children.length > 0 && expanded.has(node.id)) { event.preventDefault(); toggle(false); } else focus(parent);
59
+ break;
60
+ case 'Enter':
61
+ case ' ':
62
+ event.preventDefault();
63
+ setSelected(node.id);
64
+ interactions?.onSelect?.();
65
+ break;
66
+ default: break;
67
+ }
68
+ };
69
+
70
+ return (
71
+ <FoundationFrame element={emptyElement} interactions={interactions} name='org-tree' state={state} skeleton={[50, 70, 70, 60]}>
72
+ {(state === ComponentState.Ready || state === ComponentState.Partial) && (
73
+ <ul role='tree' aria-label={strings.orgTree} className='foundation-tree'>
74
+ {visible.map(node => {
75
+ const siblings = placed.get(node.id)?.siblings ?? [node];
76
+ const reports = descendantCount(node);
77
+ const open = expanded.has(node.id);
78
+ return (
79
+ <li key={node.id} id={`${element.id}-${node.id}`} role='treeitem' tabIndex={node.id === tabStop ? 0 : -1} aria-level={node.depth}
80
+ aria-setsize={siblings.length} aria-posinset={siblings.findIndex(sibling => sibling.id === node.id) + 1} aria-expanded={node.children.length > 0 ? open : undefined} aria-selected={node.id === selected}
81
+ className='foundation-tree-item' data-selected={node.id === selected} style={{ paddingInlineStart: `calc(var(--foundation-space-4) * ${node.depth - 1})` }}
82
+ onKeyDown={event => move(node, event)} onFocus={() => setFocused(node.id)} onClick={() => { setSelected(node.id); interactions?.onSelect?.(); }}>
83
+ {node.children.length > 0 ? (
84
+ <button type='button' tabIndex={-1} className='foundation-icon-button' aria-hidden='true'
85
+ onClick={event => { event.stopPropagation(); setExpanded(current => { const next = new Set(current); if (open) next.delete(node.id); else next.add(node.id); return next; }); }}>
86
+ <FoundationIcon name={open ? 'chevron-down' : 'chevron-right'} />
87
+ </button>
88
+ ) : <span className='foundation-tree-spacer' />}
89
+ <span className='foundation-tree-text'>
90
+ <strong>{node.label}</strong>
91
+ {node.title && <span className='foundation-count'>{node.title}</span>}
92
+ </span>
93
+ {reports > 0 && <span className='foundation-count'>{formatText(strings.orgReports, { count: reports })}</span>}
94
+ </li>
95
+ );
96
+ })}
97
+ </ul>
98
+ )}
99
+ </FoundationFrame>
100
+ );
101
+ }
@@ -0,0 +1,82 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import { Fragment, useEffect, useMemo, useState } from 'react';
5
+ import { RegisteredComponentProps, useValueOutput } from '@cratis/scene.react';
6
+ import { ComponentState, readComponentState } from './ComponentState';
7
+ import { FoundationIcon } from './FoundationIcon';
8
+ import { formatText, readFoundationStrings } from './foundationStrings';
9
+ import { isGranted, MatrixGrant, readGrants, readPermissions, readRoles, sectionsOf, setGrant } from './permissionModel';
10
+ import { readPermissionRule } from './PermissionRule';
11
+ import { FoundationFrame } from './StateFrame';
12
+
13
+ /**
14
+ * The `permissionMatrix` component: roles across, permissions down, a checkbox where they meet.
15
+ *
16
+ * `roles` and `permissions` (optionally grouped) give the axes and `grants` - rows of `role` and `permission` - what is held; a grant of a role
17
+ * or permission that does not exist is ignored. Each box is named in words ("Grant Edit deals to Sales") and every header is a real header
18
+ * cell, so a screen reader announces the row and column of the box it is on. Changes are published as the `value` output - the grants,
19
+ * in the same shape - for the screen to save. With `editPermission` not enabled the matrix is read-only and each cell says "Allowed" or
20
+ * "Not allowed" in words and by icon. Wide matrices scroll sideways inside a keyboard-focusable region, keeping the permission column in view.
21
+ */
22
+ export function PermissionMatrix({ element, interactions, bindingOutputs }: RegisteredComponentProps) {
23
+ const roles = useMemo(() => readRoles(element.properties.roles), [element.properties.roles]);
24
+ const permissions = useMemo(() => readPermissions(element.properties.permissions), [element.properties.permissions]);
25
+ const supplied = useMemo(() => readGrants(element.properties.grants, roles, permissions), [element.properties.grants, roles, permissions]);
26
+ const suppliedKey = JSON.stringify(supplied);
27
+ const [grants, setGrants] = useState<MatrixGrant[]>(supplied);
28
+ const strings = readFoundationStrings(element);
29
+ const state = readComponentState(element, roles.length > 0 && permissions.length > 0);
30
+ const readOnly = readPermissionRule(element, 'editPermission') !== 'enabled';
31
+ const sections = useMemo(() => sectionsOf(permissions), [permissions]);
32
+
33
+ useEffect(() => setGrants(supplied), [suppliedKey]);
34
+ useValueOutput(bindingOutputs, grants);
35
+
36
+ return (
37
+ <FoundationFrame element={element} interactions={interactions} name='permission-matrix' state={state} skeleton={[100, 100, 100, 100]}>
38
+ {(state === ComponentState.Ready || state === ComponentState.Partial) && (
39
+ <div className='foundation-matrix-scroll' role='region' tabIndex={0} aria-label={strings.matrixScroll}>
40
+ <table className='foundation-matrix'>
41
+ <thead>
42
+ <tr>
43
+ <th scope='col'>{strings.matrixPermission}</th>
44
+ {roles.map(role => <th key={role.id} scope='col'>{role.label}</th>)}
45
+ </tr>
46
+ </thead>
47
+ <tbody>
48
+ {sections.map((section, index) => (
49
+ <Fragment key={section.group ?? `ungrouped-${index}`}>
50
+ {section.group && (
51
+ <tr className='foundation-matrix-group'><th scope='colgroup' colSpan={roles.length + 1}>{section.group}</th></tr>
52
+ )}
53
+ {section.permissions.map(permission => (
54
+ <tr key={permission.id}>
55
+ <th scope='row'>{permission.label}</th>
56
+ {roles.map(role => {
57
+ const granted = isGranted(grants, role.id, permission.id);
58
+ return (
59
+ <td key={role.id} data-granted={granted}>
60
+ {readOnly ? (
61
+ <span className='foundation-matrix-state'>
62
+ <FoundationIcon name={granted ? 'check' : 'minus'} />
63
+ <span className='foundation-visually-hidden'>{granted ? strings.matrixAllowed : strings.matrixDenied}</span>
64
+ </span>
65
+ ) : (
66
+ <input type='checkbox' checked={granted} aria-label={formatText(strings.matrixGrant, { permission: permission.label, role: role.label })}
67
+ onChange={event => { setGrants(setGrant(grants, role.id, permission.id, event.target.checked)); interactions?.onChange?.(); }} />
68
+ )}
69
+ </td>
70
+ );
71
+ })}
72
+ </tr>
73
+ ))}
74
+ </Fragment>
75
+ ))}
76
+ </tbody>
77
+ </table>
78
+ </div>
79
+ )}
80
+ </FoundationFrame>
81
+ );
82
+ }