@cratis/scene.blueprint.default 4.39.0 → 4.40.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 (167) hide show
  1. package/ComponentName.ts +27 -0
  2. package/defaultBlueprintComponents.ts +10 -1
  3. package/dist/cjs/ComponentName.js +9 -0
  4. package/dist/cjs/ComponentName.js.map +1 -1
  5. package/dist/cjs/defaultBlueprintComponents.js +19 -1
  6. package/dist/cjs/defaultBlueprintComponents.js.map +1 -1
  7. package/dist/cjs/foundation/BulkBar.js +62 -0
  8. package/dist/cjs/foundation/BulkBar.js.map +1 -0
  9. package/dist/cjs/foundation/DataList.js +436 -0
  10. package/dist/cjs/foundation/DataList.js.map +1 -0
  11. package/dist/cjs/foundation/FormScreenBody.js +447 -0
  12. package/dist/cjs/foundation/FormScreenBody.js.map +1 -0
  13. package/dist/cjs/foundation/FoundationStates.js +2 -1
  14. package/dist/cjs/foundation/FoundationStates.js.map +1 -1
  15. package/dist/cjs/foundation/GlobalSearch.js +54 -0
  16. package/dist/cjs/foundation/GlobalSearch.js.map +1 -0
  17. package/dist/cjs/foundation/KanbanBoard.js +222 -0
  18. package/dist/cjs/foundation/KanbanBoard.js.map +1 -0
  19. package/dist/cjs/foundation/NotificationsButton.js +45 -0
  20. package/dist/cjs/foundation/NotificationsButton.js.map +1 -0
  21. package/dist/cjs/foundation/SubNavigation.js +72 -0
  22. package/dist/cjs/foundation/SubNavigation.js.map +1 -0
  23. package/dist/cjs/foundation/TabSet.js +101 -0
  24. package/dist/cjs/foundation/TabSet.js.map +1 -0
  25. package/dist/cjs/foundation/WizardProgress.js +185 -0
  26. package/dist/cjs/foundation/WizardProgress.js.map +1 -0
  27. package/dist/cjs/foundation/boardModel.js +67 -0
  28. package/dist/cjs/foundation/boardModel.js.map +1 -0
  29. package/dist/cjs/foundation/formModel.js +158 -0
  30. package/dist/cjs/foundation/formModel.js.map +1 -0
  31. package/dist/cjs/foundation/foundationComponentMetadata.js +54 -0
  32. package/dist/cjs/foundation/foundationComponentMetadata.js.map +1 -1
  33. package/dist/cjs/foundation/foundationDescriptors.js +400 -0
  34. package/dist/cjs/foundation/foundationDescriptors.js.map +1 -1
  35. package/dist/cjs/foundation/foundationSamples.js +303 -0
  36. package/dist/cjs/foundation/foundationSamples.js.map +1 -1
  37. package/dist/cjs/foundation/foundationStrings.js +72 -0
  38. package/dist/cjs/foundation/foundationStrings.js.map +1 -1
  39. package/dist/cjs/foundation/listModel.js +200 -0
  40. package/dist/cjs/foundation/listModel.js.map +1 -0
  41. package/dist/cjs/foundation/tabModel.js +41 -0
  42. package/dist/cjs/foundation/tabModel.js.map +1 -0
  43. package/dist/cjs/foundation/urlState.js +57 -0
  44. package/dist/cjs/foundation/urlState.js.map +1 -0
  45. package/dist/cjs/foundation/useCompactWidth.js +23 -0
  46. package/dist/cjs/foundation/useCompactWidth.js.map +1 -0
  47. package/dist/cjs/foundation/wizardModel.js +50 -0
  48. package/dist/cjs/foundation/wizardModel.js.map +1 -0
  49. package/dist/cjs/index.js +71 -0
  50. package/dist/cjs/index.js.map +1 -1
  51. package/dist/esm/ComponentName.d.ts +10 -1
  52. package/dist/esm/ComponentName.d.ts.map +1 -1
  53. package/dist/esm/ComponentName.js +9 -0
  54. package/dist/esm/ComponentName.js.map +1 -1
  55. package/dist/esm/defaultBlueprintComponents.d.ts.map +1 -1
  56. package/dist/esm/defaultBlueprintComponents.js +19 -1
  57. package/dist/esm/defaultBlueprintComponents.js.map +1 -1
  58. package/dist/esm/foundation/BulkBar.d.ts +4 -0
  59. package/dist/esm/foundation/BulkBar.d.ts.map +1 -0
  60. package/dist/esm/foundation/BulkBar.js +59 -0
  61. package/dist/esm/foundation/BulkBar.js.map +1 -0
  62. package/dist/esm/foundation/DataList.d.ts +3 -0
  63. package/dist/esm/foundation/DataList.d.ts.map +1 -0
  64. package/dist/esm/foundation/DataList.js +434 -0
  65. package/dist/esm/foundation/DataList.js.map +1 -0
  66. package/dist/esm/foundation/FormScreenBody.d.ts +3 -0
  67. package/dist/esm/foundation/FormScreenBody.d.ts.map +1 -0
  68. package/dist/esm/foundation/FormScreenBody.js +445 -0
  69. package/dist/esm/foundation/FormScreenBody.js.map +1 -0
  70. package/dist/esm/foundation/FoundationStates.js +2 -1
  71. package/dist/esm/foundation/FoundationStates.js.map +1 -1
  72. package/dist/esm/foundation/GlobalSearch.d.ts +3 -0
  73. package/dist/esm/foundation/GlobalSearch.d.ts.map +1 -0
  74. package/dist/esm/foundation/GlobalSearch.js +52 -0
  75. package/dist/esm/foundation/GlobalSearch.js.map +1 -0
  76. package/dist/esm/foundation/KanbanBoard.d.ts +3 -0
  77. package/dist/esm/foundation/KanbanBoard.d.ts.map +1 -0
  78. package/dist/esm/foundation/KanbanBoard.js +220 -0
  79. package/dist/esm/foundation/KanbanBoard.js.map +1 -0
  80. package/dist/esm/foundation/NotificationsButton.d.ts +3 -0
  81. package/dist/esm/foundation/NotificationsButton.d.ts.map +1 -0
  82. package/dist/esm/foundation/NotificationsButton.js +43 -0
  83. package/dist/esm/foundation/NotificationsButton.js.map +1 -0
  84. package/dist/esm/foundation/SubNavigation.d.ts +3 -0
  85. package/dist/esm/foundation/SubNavigation.d.ts.map +1 -0
  86. package/dist/esm/foundation/SubNavigation.js +70 -0
  87. package/dist/esm/foundation/SubNavigation.js.map +1 -0
  88. package/dist/esm/foundation/TabSet.d.ts +3 -0
  89. package/dist/esm/foundation/TabSet.d.ts.map +1 -0
  90. package/dist/esm/foundation/TabSet.js +99 -0
  91. package/dist/esm/foundation/TabSet.js.map +1 -0
  92. package/dist/esm/foundation/WizardProgress.d.ts +3 -0
  93. package/dist/esm/foundation/WizardProgress.d.ts.map +1 -0
  94. package/dist/esm/foundation/WizardProgress.js +183 -0
  95. package/dist/esm/foundation/WizardProgress.js.map +1 -0
  96. package/dist/esm/foundation/boardModel.d.ts +22 -0
  97. package/dist/esm/foundation/boardModel.d.ts.map +1 -0
  98. package/dist/esm/foundation/boardModel.js +61 -0
  99. package/dist/esm/foundation/boardModel.js.map +1 -0
  100. package/dist/esm/foundation/formModel.d.ts +48 -0
  101. package/dist/esm/foundation/formModel.d.ts.map +1 -0
  102. package/dist/esm/foundation/formModel.js +147 -0
  103. package/dist/esm/foundation/formModel.js.map +1 -0
  104. package/dist/esm/foundation/foundationComponentMetadata.d.ts.map +1 -1
  105. package/dist/esm/foundation/foundationComponentMetadata.js +54 -0
  106. package/dist/esm/foundation/foundationComponentMetadata.js.map +1 -1
  107. package/dist/esm/foundation/foundationDescriptors.d.ts.map +1 -1
  108. package/dist/esm/foundation/foundationDescriptors.js +400 -0
  109. package/dist/esm/foundation/foundationDescriptors.js.map +1 -1
  110. package/dist/esm/foundation/foundationSamples.d.ts.map +1 -1
  111. package/dist/esm/foundation/foundationSamples.js +303 -0
  112. package/dist/esm/foundation/foundationSamples.js.map +1 -1
  113. package/dist/esm/foundation/foundationStrings.d.ts +72 -0
  114. package/dist/esm/foundation/foundationStrings.d.ts.map +1 -1
  115. package/dist/esm/foundation/foundationStrings.js +72 -0
  116. package/dist/esm/foundation/foundationStrings.js.map +1 -1
  117. package/dist/esm/foundation/index.d.ts +16 -0
  118. package/dist/esm/foundation/index.d.ts.map +1 -1
  119. package/dist/esm/foundation/index.js +16 -0
  120. package/dist/esm/foundation/index.js.map +1 -1
  121. package/dist/esm/foundation/listModel.d.ts +49 -0
  122. package/dist/esm/foundation/listModel.d.ts.map +1 -0
  123. package/dist/esm/foundation/listModel.js +183 -0
  124. package/dist/esm/foundation/listModel.js.map +1 -0
  125. package/dist/esm/foundation/tabModel.d.ts +10 -0
  126. package/dist/esm/foundation/tabModel.d.ts.map +1 -0
  127. package/dist/esm/foundation/tabModel.js +37 -0
  128. package/dist/esm/foundation/tabModel.js.map +1 -0
  129. package/dist/esm/foundation/urlState.d.ts +4 -0
  130. package/dist/esm/foundation/urlState.d.ts.map +1 -0
  131. package/dist/esm/foundation/urlState.js +53 -0
  132. package/dist/esm/foundation/urlState.js.map +1 -0
  133. package/dist/esm/foundation/useCompactWidth.d.ts +3 -0
  134. package/dist/esm/foundation/useCompactWidth.d.ts.map +1 -0
  135. package/dist/esm/foundation/useCompactWidth.js +20 -0
  136. package/dist/esm/foundation/useCompactWidth.js.map +1 -0
  137. package/dist/esm/foundation/wizardModel.d.ts +16 -0
  138. package/dist/esm/foundation/wizardModel.d.ts.map +1 -0
  139. package/dist/esm/foundation/wizardModel.js +43 -0
  140. package/dist/esm/foundation/wizardModel.js.map +1 -0
  141. package/dist/esm/index.js +16 -0
  142. package/dist/esm/index.js.map +1 -1
  143. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  144. package/foundation/BulkBar.tsx +38 -0
  145. package/foundation/DataList.tsx +208 -0
  146. package/foundation/FormScreenBody.tsx +196 -0
  147. package/foundation/FoundationStates.tsx +1 -1
  148. package/foundation/GlobalSearch.tsx +33 -0
  149. package/foundation/KanbanBoard.tsx +97 -0
  150. package/foundation/NotificationsButton.tsx +28 -0
  151. package/foundation/SubNavigation.tsx +49 -0
  152. package/foundation/TabSet.tsx +65 -0
  153. package/foundation/WizardProgress.tsx +109 -0
  154. package/foundation/boardModel.ts +64 -0
  155. package/foundation/formModel.ts +146 -0
  156. package/foundation/foundationComponentMetadata.ts +9 -0
  157. package/foundation/foundationDescriptors.ts +195 -0
  158. package/foundation/foundationSamples.ts +56 -0
  159. package/foundation/foundationStrings.ts +72 -0
  160. package/foundation/index.ts +16 -0
  161. package/foundation/listModel.ts +190 -0
  162. package/foundation/tabModel.ts +41 -0
  163. package/foundation/urlState.ts +52 -0
  164. package/foundation/useCompactWidth.ts +23 -0
  165. package/foundation/wizardModel.ts +54 -0
  166. package/package.json +8 -8
  167. package/shell/layout.css +513 -0
@@ -0,0 +1,38 @@
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 { RegisteredComponentProps } from '@cratis/scene.react';
5
+ import { ActionButton } from './ActionButton';
6
+ import { formatText, readFoundationStrings } from './foundationStrings';
7
+ import { readPermissionRule } from './PermissionRule';
8
+
9
+ /** How many records a selection holds: an array's length, a number as given, anything else none. */
10
+ export function selectionCount(selection: unknown): number {
11
+ if (Array.isArray(selection)) return selection.length;
12
+ return typeof selection === 'number' && Number.isFinite(selection) && selection > 0 ? Math.floor(selection) : 0;
13
+ }
14
+
15
+ /**
16
+ * The `bulkBar` component: appears when records are selected, says how many, offers the bulk actions and clears the selection.
17
+ *
18
+ * `selection` is the selected records (or their count) - a list screen binds it to the table's selection output - and the bar is not rendered
19
+ * at all while nothing is selected. It is announced politely when it appears or its count changes. `actions` (strings) are the bulk actions; each
20
+ * runs the behaviors attached to the element and honours `actionPermission` (enabled, disabled with its reason, or hidden). "Clear selection"
21
+ * runs the same behaviors with no action, which a screen wires to emptying the table's selection.
22
+ */
23
+ export function BulkBar({ element, interactions }: RegisteredComponentProps) {
24
+ const count = selectionCount(element.properties.selection);
25
+ const strings = readFoundationStrings(element);
26
+ const actions = Array.isArray(element.properties.actions) ? element.properties.actions.filter((action): action is string => typeof action === 'string') : [];
27
+ const rule = readPermissionRule(element, 'actionPermission');
28
+
29
+ return (
30
+ <div className='foundation foundation-bulk-bar' data-scene-id={element.id} role='region' aria-label={strings.bulkActions} hidden={count === 0}>
31
+ <span className='foundation-bulk-count' role='status'>{count > 0 ? formatText(strings.bulkSelected, { count }) : ''}</span>
32
+ <span className='foundation-bulk-actions'>
33
+ {actions.map(action => <ActionButton key={action} rule={rule} emphasis='secondary' disabledReason={strings.disabledNoPermission} onClick={interactions?.onClick}>{action}</ActionButton>)}
34
+ </span>
35
+ <ActionButton emphasis='quiet' disabledReason={strings.disabledNoPermission} onClick={interactions?.onClick}>{strings.bulkClear}</ActionButton>
36
+ </div>
37
+ );
38
+ }
@@ -0,0 +1,208 @@
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 { readOptionalString } from '../shell/elementProperties';
7
+ import { ComponentState, readComponentState } from './ComponentState';
8
+ import { FoundationIcon } from './FoundationIcon';
9
+ import { FoundationFrame } from './StateFrame';
10
+ import { formatDate, formatNumber, NumberFormat, readLocale } from './formatting';
11
+ import { formatText, readFoundationStrings } from './foundationStrings';
12
+ import { readPermissionRule } from './PermissionRule';
13
+ import {
14
+ CellKind, ListColumn, ListRow, ListVariant, filterRows, formatSort, groupRows, nextSort, paginate, parseSelection, parseSort, readListColumns, readListVariant, rowId,
15
+ searchRows, sortRows, textOf, toggleSelection,
16
+ } from './listModel';
17
+ import { useCompactWidth } from './useCompactWidth';
18
+ import { useUrlState } from './urlState';
19
+
20
+ function Cell({ column, row, locale }: { column: ListColumn; row: ListRow; locale: string }) {
21
+ const raw = row[column.field];
22
+ const text = textOf(row, column.field);
23
+ switch (column.kind) {
24
+ case CellKind.Number: return <>{typeof raw === 'number' ? formatNumber(raw, locale) : text}</>;
25
+ case CellKind.Currency: return <>{typeof raw === 'number' ? formatNumber(raw, locale, NumberFormat.Currency, column.currency ?? 'USD') : text}</>;
26
+ case CellKind.Date: return <>{formatDate(raw, locale)}</>;
27
+ case CellKind.Status: {
28
+ const tone = column.tones[text] ?? 'neutral';
29
+ const icon = ({ success: 'check', warning: 'alert', danger: 'close', info: 'info', neutral: 'circle' } as const)[tone as 'success'] ?? 'circle';
30
+ return <span className='foundation-chip' data-tone={tone}><FoundationIcon name={icon} /><span>{text}</span></span>;
31
+ }
32
+ default: return <>{text}</>;
33
+ }
34
+ }
35
+
36
+ /**
37
+ * The `dataList` component: the records of a collection as a table, as cards, or grouped with subtotals, with search, sort, paging and
38
+ * selection that live in the address.
39
+ *
40
+ * `rows` and `columns` (`field`, `header`, `kind`: text, number, currency, date or status, `sortable`, `tones`) come from a query; the list takes its
41
+ * text, filter, sort, page and selection from the address when `urlKey` is given (`<urlKey>q`, `sort`, `page`, `selected`), so a shared link or a refresh restores
42
+ * the screen as it was. `search` and `filter` (the filter builder's value) narrow the rows. A header is a button that cycles ascending, descending, none, and the
43
+ * column says so in words. Rows select with checkboxes (`selectionMode` multiple or single), and a row's "Actions" menu names its row. On a phone a table becomes cards -
44
+ * nothing is hidden or cut. The selection is the `value` output; a row action is the `action` output. Header, row and cell elements are real table elements,
45
+ * so a screen reader announces the row and column of the cell it is on.
46
+ */
47
+ export function DataList({ element, interactions, bindingOutputs }: RegisteredComponentProps) {
48
+ const columns = useMemo(() => readListColumns(element.properties.columns), [element.properties.columns]);
49
+ const rows = useMemo(() => (Array.isArray(element.properties.rows) ? (element.properties.rows as unknown[]).filter((row): row is ListRow => typeof row === 'object' && row !== null && !Array.isArray(row)) : []), [element.properties.rows]);
50
+ const idField = readOptionalString(element, 'idField') ?? 'id';
51
+ const titleField = readOptionalString(element, 'titleField') ?? columns[0]?.field ?? idField;
52
+ const urlKey = readOptionalString(element, 'urlKey');
53
+ const keyed = (name: string) => (urlKey === undefined ? undefined : `${urlKey}${name}`);
54
+ const [searchText, setSearchText] = useUrlState(keyed('q'), readOptionalString(element, 'search'));
55
+ const [sortText, setSortText] = useUrlState(keyed('sort'), readOptionalString(element, 'sort'));
56
+ const [pageText, setPageText] = useUrlState(keyed('page'), '1');
57
+ const [selectedText, setSelectedText] = useUrlState(keyed('selected'), readOptionalString(element, 'selected'));
58
+ const [openGroups, setOpenGroups] = useState<Set<string> | undefined>();
59
+ const strings = readFoundationStrings(element);
60
+ const locale = readLocale(element);
61
+ const compact = useCompactWidth();
62
+ const requestedVariant = readListVariant(element.properties.variant);
63
+ const variant = requestedVariant === ListVariant.Table && compact && element.properties.collapse !== false ? ListVariant.Cards : requestedVariant;
64
+ const pageSize = typeof element.properties.pageSize === 'number' ? element.properties.pageSize : 0;
65
+ const multiple = element.properties.selectionMode !== 'single';
66
+ const selectable = element.properties.selectionMode === 'multiple' || element.properties.selectionMode === 'single';
67
+ const actions = Array.isArray(element.properties.rowActions) ? element.properties.rowActions.filter((action): action is string => typeof action === 'string') : [];
68
+ const actionRule = readPermissionRule(element, 'actionPermission');
69
+ const groupBy = readOptionalString(element, 'groupBy');
70
+ const totalField = readOptionalString(element, 'totalField');
71
+ const sort = parseSort(sortText, columns);
72
+
73
+ const matching = useMemo(() => filterRows(searchRows(rows, columns, searchText ?? ''), element.properties.filter), [rows, columns, searchText, element.properties.filter]);
74
+ const sorted = useMemo(() => sortRows(matching, sort, locale), [matching, sort?.field, sort?.descending, locale]);
75
+ const page = paginate(sorted, Number(pageText), variant === ListVariant.Grouped ? 0 : pageSize);
76
+ const selected = parseSelection(selectedText);
77
+ const state = readComponentState(element, rows.length > 0);
78
+
79
+ useEffect(() => { if (Number(pageText) !== page.page && pageText !== undefined) setPageText(String(page.page)); }, [page.page, pageText, setPageText]);
80
+ useValueOutput(bindingOutputs, multiple ? selected : selected[0]);
81
+
82
+ const select = (id: string) => { setSelectedText(toggleSelection(selected, id, multiple).join(',') || undefined); interactions?.onSelect?.(); };
83
+ const run = (action: string, id: string) => { bindingOutputs?.setOutput('action', { action, row: id }); interactions?.onClick?.({ stopPropagation: () => undefined }); };
84
+ const sortLabel = (column: ListColumn) => (sort?.field === column.field ? `${column.header}, ${sort.descending ? strings.listSortedDescending : strings.listSortedAscending}` : column.header);
85
+ const nameOf = (row: ListRow, index: number) => textOf(row, titleField) || rowId(row, index, idField);
86
+ const allOnPage = page.rows.length > 0 && page.rows.every((row, index) => selected.includes(rowId(row, index, idField)));
87
+ const count = matching.length === rows.length ? formatText(strings.listCount, { count: rows.length }) : formatText(strings.listCountFiltered, { shown: matching.length, total: rows.length });
88
+
89
+ const rowActions = (row: ListRow, index: number) => actions.length === 0 || actionRule === 'hidden' ? undefined : (
90
+ <select className='foundation-row-actions' aria-label={formatText(strings.listRowActions, { name: nameOf(row, index) })} value='' disabled={actionRule === 'disabled'} title={actionRule === 'disabled' ? strings.disabledNoPermission : undefined}
91
+ onChange={event => { if (event.target.value) run(event.target.value, rowId(row, index, idField)); }}>
92
+ <option value=''>{strings.listActions}</option>
93
+ {actions.map(action => <option key={action} value={action}>{action}</option>)}
94
+ </select>
95
+ );
96
+
97
+ const checkbox = (row: ListRow, index: number) => !selectable ? undefined : (
98
+ <input type={multiple ? 'checkbox' : 'radio'} name={`${element.id}-select`} checked={selected.includes(rowId(row, index, idField))} aria-label={formatText(strings.listSelectRow, { name: nameOf(row, index) })} onChange={() => select(rowId(row, index, idField))} />
99
+ );
100
+
101
+ const table = (tableRows: ListRow[], offset = 0, caption?: string) => (
102
+ <div className='foundation-table-scroll'>
103
+ <table className='foundation-table'>
104
+ <caption className='foundation-visually-hidden'>{caption ?? readOptionalString(element, 'label') ?? strings.listTable}</caption>
105
+ <thead>
106
+ <tr>
107
+ {selectable && (
108
+ <th scope='col' className='foundation-table-select'>
109
+ {multiple && <input type='checkbox' checked={allOnPage} aria-label={strings.listSelectAll}
110
+ onChange={() => setSelectedText((allOnPage ? selected.filter(id => !page.rows.some((row, index) => rowId(row, index, idField) === id)) : [...new Set([...selected, ...page.rows.map((row, index) => rowId(row, index, idField))])]).join(',') || undefined)} />}
111
+ </th>
112
+ )}
113
+ {columns.map(column => (
114
+ <th key={column.field} scope='col' aria-sort={sort?.field === column.field ? (sort.descending ? 'descending' : 'ascending') : undefined}>
115
+ {column.sortable ? (
116
+ <button type='button' className='foundation-sort' aria-label={formatText(strings.listSortBy, { column: sortLabel(column) })} onClick={() => { setSortText(formatSort(nextSort(sort, column.field))); setPageText('1'); }}>
117
+ <span>{column.header}</span>
118
+ {sort?.field === column.field && <FoundationIcon name={sort.descending ? 'arrow-down' : 'arrow-up'} />}
119
+ </button>
120
+ ) : column.header}
121
+ </th>
122
+ ))}
123
+ {actions.length > 0 && actionRule !== 'hidden' && <th scope='col'><span className='foundation-visually-hidden'>{strings.listActions}</span></th>}
124
+ </tr>
125
+ </thead>
126
+ <tbody>
127
+ {tableRows.map((row, position) => {
128
+ const index = offset + position;
129
+ const id = rowId(row, index, idField);
130
+ return (
131
+ <tr key={id} aria-selected={selectable ? selected.includes(id) : undefined} data-selected={selected.includes(id)}>
132
+ {selectable && <td className='foundation-table-select'>{checkbox(row, index)}</td>}
133
+ {columns.map((column, columnIndex) => columnIndex === 0
134
+ ? <th key={column.field} scope='row'><Cell column={column} row={row} locale={locale} /></th>
135
+ : <td key={column.field} data-kind={column.kind}><Cell column={column} row={row} locale={locale} /></td>)}
136
+ {actions.length > 0 && actionRule !== 'hidden' && <td>{rowActions(row, index)}</td>}
137
+ </tr>
138
+ );
139
+ })}
140
+ </tbody>
141
+ </table>
142
+ </div>
143
+ );
144
+
145
+ const cards = (cardRows: ListRow[], offset = 0) => (
146
+ <ul className='foundation-cards' aria-label={readOptionalString(element, 'label') ?? strings.listCards}>
147
+ {cardRows.map((row, position) => {
148
+ const index = offset + position;
149
+ const id = rowId(row, index, idField);
150
+ return (
151
+ <li key={id} className='foundation-card-row' data-selected={selected.includes(id)}>
152
+ <div className='foundation-card-head'>{checkbox(row, index)}<strong>{nameOf(row, index)}</strong></div>
153
+ <dl>
154
+ {columns.filter(column => column.field !== titleField).map(column => (
155
+ <div key={column.field}><dt className='foundation-count'>{column.header}</dt><dd><Cell column={column} row={row} locale={locale} /></dd></div>
156
+ ))}
157
+ </dl>
158
+ {rowActions(row, index)}
159
+ </li>
160
+ );
161
+ })}
162
+ </ul>
163
+ );
164
+
165
+ const groups = variant === ListVariant.Grouped && groupBy ? groupRows(sorted, groupBy, totalField) : [];
166
+ const isOpen = (key: string) => (openGroups === undefined ? true : openGroups.has(key));
167
+ const toggleGroup = (key: string) => setOpenGroups(current => { const base = current ?? new Set(groups.map(group => group.key)); const next = new Set(base); if (next.has(key)) next.delete(key); else next.add(key); return next; });
168
+ const totalColumn = columns.find(column => column.field === totalField);
169
+
170
+ return (
171
+ <FoundationFrame element={element} interactions={interactions} name='data-list' state={state} skeleton={[100, 100, 100, 100]}>
172
+ {(state === ComponentState.Ready || state === ComponentState.Partial) && (
173
+ <>
174
+ <p className='foundation-count' role='status'>{count}</p>
175
+ {matching.length === 0 ? (
176
+ <div className='foundation-empty' role='status'>
177
+ <strong className='foundation-empty-headline'>{strings.listNoMatches}</strong>
178
+ <button type='button' className='foundation-link' onClick={() => { setSearchText(undefined); setPageText('1'); interactions?.onChange?.(); }}>{strings.listClearSearch}</button>
179
+ </div>
180
+ ) : variant === ListVariant.Grouped && groupBy ? groups.map(group => (
181
+ <section key={group.key} className='foundation-list-group'>
182
+ <h3>
183
+ <button type='button' aria-expanded={isOpen(group.key)} className='foundation-sort' onClick={() => toggleGroup(group.key)}
184
+ aria-label={formatText(isOpen(group.key) ? strings.listGroupCollapse : strings.listGroupExpand, { group: group.key })}>
185
+ <FoundationIcon name={isOpen(group.key) ? 'chevron-down' : 'chevron-right'} />{group.key}
186
+ </button>
187
+ <span className='foundation-count'>{group.subtotal === undefined ? group.rows.length : formatText(strings.listGroupSubtotal, { count: group.rows.length, total: formatNumber(group.subtotal, locale, totalColumn?.kind === CellKind.Currency ? NumberFormat.Currency : NumberFormat.Number, totalColumn?.currency ?? 'USD') })}</span>
188
+ </h3>
189
+ {isOpen(group.key) && table(group.rows, 0, group.key)}
190
+ </section>
191
+ )) : (
192
+ <>
193
+ {variant === ListVariant.Cards ? cards(page.rows, (page.page - 1) * Math.max(1, pageSize)) : table(page.rows, (page.page - 1) * Math.max(1, pageSize))}
194
+ {page.pages > 1 && (
195
+ <nav className='foundation-pager' aria-label={formatText(strings.listPage, { page: page.page, pages: page.pages })}>
196
+ <button type='button' className='foundation-button' data-emphasis='secondary' disabled={page.page <= 1} onClick={() => setPageText(String(page.page - 1))}>{strings.listPrevious}</button>
197
+ <span aria-current='page'>{formatText(strings.listPage, { page: page.page, pages: page.pages })}</span>
198
+ <button type='button' className='foundation-button' data-emphasis='secondary' disabled={page.page >= page.pages} onClick={() => setPageText(String(page.page + 1))}>{strings.listNext}</button>
199
+ </nav>
200
+ )}
201
+ </>
202
+ )}
203
+ </>
204
+ )}
205
+ </FoundationFrame>
206
+ );
207
+ }
208
+
@@ -0,0 +1,196 @@
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, useId, useMemo, useRef, useState } from 'react';
5
+ import { RegisteredComponentProps, useValueOutput } from '@cratis/scene.react';
6
+ import { ActionButton } from './ActionButton';
7
+ import { ComponentState, readComponentState } from './ComponentState';
8
+ import { FormField, FormFieldType, FormMode, FormSection, FormValues, addressLines, mapServerErrors, readFormMode, readFormSections, sameValues, validate } from './formModel';
9
+ import { readLocale } from './formatting';
10
+ import { formatText, readFoundationStrings } from './foundationStrings';
11
+ import { readPermissionRule } from './PermissionRule';
12
+ import { FoundationIcon } from './FoundationIcon';
13
+ import { FoundationFrame } from './StateFrame';
14
+
15
+ interface FieldProps {
16
+ field: FormField;
17
+ value: unknown;
18
+ error?: string;
19
+ readOnly: boolean;
20
+ locale: string;
21
+ required: string;
22
+ choose: string;
23
+ set(value: unknown): void;
24
+ blur(): void;
25
+ }
26
+
27
+ function FieldInput({ field, value, error, readOnly, locale, required, choose, set, blur }: FieldProps) {
28
+ const id = useId();
29
+ const describedBy = [field.help ? `${id}-help` : undefined, error ? `${id}-error` : undefined].filter(Boolean).join(' ') || undefined;
30
+ const common = { id, 'aria-invalid': error ? true : undefined, 'aria-required': field.required || undefined, 'aria-describedby': describedBy, disabled: readOnly, onBlur: blur } as const;
31
+ const text = typeof value === 'string' || typeof value === 'number' ? String(value) : '';
32
+ let input;
33
+
34
+ switch (field.type) {
35
+ case FormFieldType.Toggle:
36
+ input = <input {...common} type='checkbox' role='switch' checked={value === true} onChange={event => set(event.target.checked)} />;
37
+ break;
38
+ case FormFieldType.Select:
39
+ case FormFieldType.Lookup:
40
+ input = <select {...common} value={text} onChange={event => set(event.target.value)}><option value=''>{choose}</option>{field.options.map(option => <option key={option} value={option}>{option}</option>)}</select>;
41
+ break;
42
+ case FormFieldType.MultiSelect:
43
+ input = (
44
+ <fieldset className='foundation-fieldset' aria-describedby={describedBy} disabled={readOnly}>
45
+ {field.options.map(option => (
46
+ <label key={option} className='foundation-check'>
47
+ <input type='checkbox' checked={Array.isArray(value) && value.includes(option)} onBlur={blur}
48
+ onChange={event => set(event.target.checked ? [...(Array.isArray(value) ? value : []), option] : (Array.isArray(value) ? value : []).filter(candidate => candidate !== option))} />
49
+ <span>{option}</span>
50
+ </label>
51
+ ))}
52
+ </fieldset>
53
+ );
54
+ break;
55
+ case FormFieldType.RichText:
56
+ input = <textarea {...common} rows={5} value={text} placeholder={field.placeholder} onChange={event => set(event.target.value)} />;
57
+ break;
58
+ case FormFieldType.File:
59
+ input = <input {...common} type='file' onChange={event => set(Array.from(event.target.files ?? []).map(file => file.name))} />;
60
+ break;
61
+ case FormFieldType.Address: {
62
+ const lines = typeof value === 'object' && value !== null ? (value as Record<string, string>) : {};
63
+ input = (
64
+ <div className='foundation-address' role='group' aria-describedby={describedBy}>
65
+ {addressLines.map(line => (
66
+ <input key={line} type='text' aria-label={`${field.label}: ${line}`} placeholder={line} disabled={readOnly} value={lines[line] ?? ''} onBlur={blur} onChange={event => set({ ...lines, [line]: event.target.value })} />
67
+ ))}
68
+ </div>
69
+ );
70
+ break;
71
+ }
72
+ case FormFieldType.Number:
73
+ case FormFieldType.Currency:
74
+ input = <input {...common} type='number' inputMode='decimal' value={text} placeholder={field.placeholder} onChange={event => set(event.target.value === '' ? '' : Number(event.target.value))} />;
75
+ break;
76
+ case FormFieldType.Date:
77
+ input = <input {...common} type='date' lang={locale} value={text} onChange={event => set(event.target.value)} />;
78
+ break;
79
+ default:
80
+ input = <input {...common} type='text' value={text} placeholder={field.placeholder} onChange={event => set(event.target.value)} />;
81
+ }
82
+
83
+ const grouped = field.type === FormFieldType.MultiSelect || field.type === FormFieldType.Address;
84
+ return (
85
+ <div className='foundation-field' style={{ gridColumn: `span ${field.span}` }} data-invalid={error ? true : undefined}>
86
+ {grouped ? <span className='foundation-field-label' id={`${id}-label`}>{field.label}{field.required && <span className='foundation-required' title={required}> *<span className='foundation-visually-hidden'> ({required})</span></span>}</span>
87
+ : <label htmlFor={id} className='foundation-field-label'>{field.label}{field.required && <span className='foundation-required' title={required}> *<span className='foundation-visually-hidden'> ({required})</span></span>}</label>}
88
+ {input}
89
+ {field.help && <span id={`${id}-help`} className='foundation-count'>{field.help}</span>}
90
+ {error && <span id={`${id}-error`} className='foundation-field-error' role='alert'>{error}</span>}
91
+ </div>
92
+ );
93
+ }
94
+
95
+ /**
96
+ * The `formScreenBody` component: titled sections of fields in a one-to-three column grid, validated, with the Save / Save and new / Cancel bar and the
97
+ * unsaved-changes guard.
98
+ *
99
+ * `sections` (`title`, `columns`, `fields`) and `values` come from the screen; a field is `text`, `number`, `currency`, `date`, `select`, `multiSelect`, `lookup`,
100
+ * `toggle`, `file`, `richText` or `address`. Validation is inline on blur and a summary on submit (an alert listing each field as a link back to it); a server's
101
+ * `errors` map onto the fields, and one for a field the form lacks is shown as a general error so it is never lost. Required fields are marked with an asterisk
102
+ * *and* the word. `mode` is `create`, `edit`, `readOnly` or `settings` - settings has one Save for each section. The edited values are the `value` output; Save
103
+ * runs the behaviors attached to the element. While values differ from the saved ones the form says so, warns the browser before the page is left, and offers
104
+ * "Discard changes". The action bar sticks to the bottom as the form scrolls.
105
+ */
106
+ export function FormScreenBody({ element, interactions, bindingOutputs }: RegisteredComponentProps) {
107
+ const sections = useMemo(() => readFormSections(element.properties.sections), [element.properties.sections]);
108
+ const mode = readFormMode(element.properties.mode);
109
+ const suppliedValues = (typeof element.properties.values === 'object' && element.properties.values !== null && !Array.isArray(element.properties.values) ? element.properties.values : {}) as FormValues;
110
+ const suppliedKey = JSON.stringify(suppliedValues);
111
+ const [saved, setSaved] = useState<FormValues>(suppliedValues);
112
+ const [values, setValues] = useState<FormValues>(suppliedValues);
113
+ const [touched, setTouched] = useState<Set<string>>(new Set());
114
+ const [submitted, setSubmitted] = useState(false);
115
+ const [justSaved, setJustSaved] = useState(false);
116
+ const summary = useRef<HTMLDivElement>(null);
117
+ const strings = readFoundationStrings(element);
118
+ const locale = readLocale(element);
119
+ const state = readComponentState(element, sections.length > 0);
120
+ const readOnly = mode === FormMode.ReadOnly || readPermissionRule(element, 'editPermission') !== 'enabled';
121
+ const canSave = readPermissionRule(element, 'savePermission');
122
+ const problems = validate(sections, values);
123
+ const server = useMemo(() => mapServerErrors(sections, element.properties.errors), [sections, element.properties.errors]);
124
+ const dirty = !sameValues(values, saved);
125
+ const labelOf = (name: string) => sections.flatMap(section => section.fields).find(field => field.name === name)?.label ?? name;
126
+ const wording = (name: string, kind: 'required' | 'invalid') => formatText(kind === 'required' ? strings.formRequiredField : strings.formInvalidField, { label: labelOf(name) });
127
+ const errorOf = (name: string) => server.byField[name] ?? ((touched.has(name) || submitted) && problems[name] ? wording(name, problems[name]) : undefined);
128
+ const invalid = Object.keys(problems);
129
+
130
+ useEffect(() => { setSaved(suppliedValues); setValues(suppliedValues); setTouched(new Set()); setSubmitted(false); }, [suppliedKey]);
131
+ useValueOutput(bindingOutputs, values);
132
+ useEffect(() => {
133
+ if (!dirty || typeof window === 'undefined') return;
134
+ const guard = (event: BeforeUnloadEvent) => { event.preventDefault(); event.returnValue = ''; };
135
+ window.addEventListener('beforeunload', guard);
136
+ return () => window.removeEventListener('beforeunload', guard);
137
+ }, [dirty]);
138
+
139
+ const submit = (scope?: FormSection) => {
140
+ setSubmitted(true);
141
+ const scoped = scope ? scope.fields.filter(field => problems[field.name]) : invalid.map(name => ({ name }));
142
+ if (scoped.length > 0) { requestAnimationFrame(() => summary.current?.focus()); return; }
143
+ setSaved(values);
144
+ setJustSaved(true);
145
+ interactions?.onClick?.({ stopPropagation: () => undefined });
146
+ };
147
+ const edit = (name: string, value: unknown) => { setValues(current => ({ ...current, [name]: value })); setJustSaved(false); interactions?.onChange?.(); };
148
+
149
+ return (
150
+ <FoundationFrame element={element} interactions={interactions} name='form-screen' state={state} skeleton={[40, 100, 100, 70]}>
151
+ {state !== ComponentState.Loading && (
152
+ <form noValidate onSubmit={event => { event.preventDefault(); if (mode !== FormMode.Settings) submit(); }}>
153
+ {readOnly && mode === FormMode.ReadOnly && <p className='foundation-count' role='status'>{strings.formReadOnly}</p>}
154
+ {(submitted && invalid.length > 0 || server.general.length > 0) && (
155
+ <div ref={summary} tabIndex={-1} role='alert' className='foundation-message' data-tone='danger'>
156
+ <FoundationIcon name='alert' />
157
+ <div className='foundation-message-text'>
158
+ <strong>{invalid.length === 1 ? strings.formSummaryOne : formatText(strings.formSummary, { count: invalid.length })}</strong>
159
+ <ul>
160
+ {invalid.map(name => <li key={name}>{wording(name, problems[name])}</li>)}
161
+ {server.general.map(message => <li key={message}>{message}</li>)}
162
+ </ul>
163
+ </div>
164
+ </div>
165
+ )}
166
+ {sections.map(section => (
167
+ <fieldset key={section.id} className='foundation-form-section' disabled={false}>
168
+ {section.title && <legend>{section.title}</legend>}
169
+ <div className='foundation-form-grid' style={{ gridTemplateColumns: `repeat(${section.columns}, minmax(0, 1fr))` }} data-columns={section.columns}>
170
+ {section.fields.map(field => (
171
+ <FieldInput key={field.name} field={field} value={values[field.name]} error={errorOf(field.name)} readOnly={readOnly} locale={locale} required={strings.formRequired} choose={strings.formChoose}
172
+ set={value => edit(field.name, value)} blur={() => setTouched(current => new Set(current).add(field.name))} />
173
+ ))}
174
+ </div>
175
+ {mode === FormMode.Settings && !readOnly && (
176
+ <div className='foundation-form-section-actions'>
177
+ <ActionButton rule={canSave} emphasis='secondary' disabledReason={strings.disabledNoPermission} onClick={() => submit(section)}>{formatText(strings.formSectionSave, { section: section.title })}</ActionButton>
178
+ </div>
179
+ )}
180
+ </fieldset>
181
+ ))}
182
+ {mode !== FormMode.Settings && mode !== FormMode.ReadOnly && (
183
+ <div className='foundation-form-actions' role='group' aria-label={strings.listActions}>
184
+ <span role='status' className='foundation-count'>{dirty ? strings.formUnsaved : justSaved ? strings.formSaved : ''}</span>
185
+ <span className='foundation-wizard-spacer' />
186
+ {dirty && <ActionButton emphasis='quiet' disabledReason={strings.disabledNoPermission} onClick={() => { setValues(saved); setTouched(new Set()); setSubmitted(false); }}>{strings.formDiscard}</ActionButton>}
187
+ <ActionButton emphasis='secondary' disabledReason={strings.disabledNoPermission} onClick={() => interactions?.onClick?.({ stopPropagation: () => undefined })}>{strings.formCancel}</ActionButton>
188
+ {mode === FormMode.Create && <ActionButton rule={canSave} emphasis='secondary' disabledReason={strings.disabledNoPermission} onClick={() => { submit(); }}>{strings.formSaveAndNew}</ActionButton>}
189
+ <ActionButton rule={canSave} disabledReason={strings.disabledNoPermission} onClick={() => submit()}>{strings.formSave}</ActionButton>
190
+ </div>
191
+ )}
192
+ </form>
193
+ )}
194
+ </FoundationFrame>
195
+ );
196
+ }
@@ -35,7 +35,7 @@ export function FoundationStates({ component, theme, properties, rightToLeft }:
35
35
  <SceneThemeProvider theme={theme === 'dark' ? darkTheme : lightTheme}>
36
36
  <div dir={rightToLeft ? 'rtl' : 'ltr'} className='foundation-states' style={{ display: 'grid', gap: '1rem', padding: '1rem', gridTemplateColumns: 'repeat(auto-fill, minmax(18rem, 1fr))', background: 'var(--scene-surface-background)', color: 'var(--scene-text-color)' }}>
37
37
  {states.map(state => (
38
- <figure key={state} style={{ margin: 0 }}>
38
+ <figure key={state} style={{ margin: 0, minInlineSize: 0 }}>
39
39
  <figcaption style={{ fontSize: '0.75rem', textTransform: 'uppercase', marginBlockEnd: '0.5rem' }}>{state}</figcaption>
40
40
  <Component element={foundationElement(component, { ...sample?.properties, ...properties }, state === ComponentState.Ready ? undefined : state)} slots={{}} />
41
41
  </figure>
@@ -0,0 +1,33 @@
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
+
5
+ import { RegisteredComponentProps, useValueOutput } from '@cratis/scene.react';
6
+ import { readOptionalString } from '../shell/elementProperties';
7
+ import { FoundationIcon } from './FoundationIcon';
8
+ import { readFoundationStrings } from './foundationStrings';
9
+ import { useUrlState } from './urlState';
10
+
11
+ /**
12
+ * The `globalSearch` component: a search box for the topbar or a list's toolbar.
13
+ *
14
+ * It is a `search` landmark around a labelled field; the text is the `value` output and, with `urlKey`, is kept in the address so a search is
15
+ * a shareable link. Pressing Escape clears it. The placeholder and the accessible name come from `placeholder` and `label`, and from the
16
+ * strings table when absent, so nothing is hard-coded.
17
+ */
18
+ export function GlobalSearch({ element, interactions, bindingOutputs }: RegisteredComponentProps) {
19
+ const strings = readFoundationStrings(element);
20
+ const [text, setText] = useUrlState(readOptionalString(element, 'urlKey'), readOptionalString(element, 'text'));
21
+ const label = readOptionalString(element, 'label') ?? strings.searchLabel;
22
+
23
+ useValueOutput(bindingOutputs, text ?? '');
24
+
25
+ return (
26
+ <form role='search' className='foundation foundation-search' data-scene-id={element.id} onSubmit={event => { event.preventDefault(); interactions?.onChange?.(); }}>
27
+ <FoundationIcon name='filter' />
28
+ <input type='search' aria-label={label} placeholder={readOptionalString(element, 'placeholder') ?? strings.searchPlaceholder} value={text ?? ''}
29
+ onChange={event => { setText(event.target.value); interactions?.onChange?.(); }}
30
+ onKeyDown={event => { if (event.key === 'Escape' && (text ?? '').length > 0) { event.preventDefault(); setText(undefined); } }} />
31
+ </form>
32
+ );
33
+ }
@@ -0,0 +1,97 @@
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 { DragEvent, useEffect, useMemo, useState } from 'react';
5
+ import { RegisteredComponentProps, useValueOutput } from '@cratis/scene.react';
6
+ import { BoardCard, BoardColumn, cardsOf, moveCard, overLimit, readCards, readColumnsOfBoard } from './boardModel';
7
+ import { ComponentState, readComponentState } from './ComponentState';
8
+ import { FoundationIcon } from './FoundationIcon';
9
+ import { formatText, readFoundationStrings } from './foundationStrings';
10
+ import { readPermissionRule } from './PermissionRule';
11
+ import { FoundationFrame } from './StateFrame';
12
+
13
+ /**
14
+ * The `kanbanBoard` component: columns mapped to a status or stage, with cards that move between them.
15
+ *
16
+ * `columns` (`id`, `label`, `limit`) and `cards` (`id`, `title`, `column`, `assignee`, up to three `fields`, `badges`) come from a query. A card moves by
17
+ * dragging it to another column **and** by its own "Move to" menu - the keyboard and screen-reader alternative, so nothing depends on a pointer
18
+ * - and every move is announced. Columns show their count and a limit indicator that says "Over the limit" in words, and collapse to a
19
+ * strip. The new arrangement is published as `value` (the cards with their columns) for the screen to save; with `editPermission` not enabled the
20
+ * board is read-only. A card whose column no longer exists is shown in the first column rather than lost.
21
+ */
22
+ export function KanbanBoard({ element, interactions, bindingOutputs }: RegisteredComponentProps) {
23
+ const columns = useMemo(() => readColumnsOfBoard(element.properties.columns), [element.properties.columns]);
24
+ const supplied = useMemo(() => readCards(element.properties.cards), [element.properties.cards]);
25
+ const suppliedKey = JSON.stringify(supplied);
26
+ const [cards, setCards] = useState<BoardCard[]>(supplied);
27
+ const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
28
+ const [announcement, setAnnouncement] = useState('');
29
+ const [dragging, setDragging] = useState<string | undefined>();
30
+ const strings = readFoundationStrings(element);
31
+ const state = readComponentState(element, columns.length > 0);
32
+ const locked = readPermissionRule(element, 'editPermission') !== 'enabled';
33
+
34
+ useEffect(() => setCards(supplied), [suppliedKey]);
35
+ useValueOutput(bindingOutputs, cards.map(card => ({ id: card.id, column: card.column })));
36
+
37
+ const move = (id: string, to: string) => {
38
+ const card = cards.find(candidate => candidate.id === id);
39
+ const target = columns.find(column => column.id === to);
40
+ const next = moveCard(columns, cards, id, to);
41
+ if (!card || !target || next === cards) return;
42
+ setCards(next);
43
+ setAnnouncement(formatText(strings.boardMoved, { title: card.title, column: target.label }));
44
+ interactions?.onChange?.();
45
+ };
46
+ const drop = (event: DragEvent, to: string) => { event.preventDefault(); if (dragging) move(dragging, to); setDragging(undefined); };
47
+ const toggle = (id: string) => setCollapsed(current => { const next = new Set(current); if (next.has(id)) next.delete(id); else next.add(id); return next; });
48
+
49
+ return (
50
+ <FoundationFrame element={element} interactions={interactions} name='kanban-board' state={state} skeleton={[100, 100, 100]}>
51
+ {(state === ComponentState.Ready || state === ComponentState.Partial) && (
52
+ <>
53
+ <div role='status' aria-live='polite' className='foundation-visually-hidden'>{announcement}</div>
54
+ <div className='foundation-board'>
55
+ {columns.map((column: BoardColumn) => {
56
+ const inColumn = cardsOf(columns, cards, column.id);
57
+ const closed = collapsed.has(column.id);
58
+ const over = overLimit(column, inColumn.length);
59
+ return (
60
+ <section key={column.id} className='foundation-board-column' data-collapsed={closed} data-over={over} aria-label={formatText(strings.boardColumn, { column: column.label, count: inColumn.length })}
61
+ onDragOver={event => { if (!locked) event.preventDefault(); }} onDrop={event => { if (!locked) drop(event, column.id); }}>
62
+ <header className='foundation-board-header'>
63
+ <button type='button' className='foundation-icon-button' aria-expanded={!closed} aria-label={formatText(closed ? strings.boardExpand : strings.boardCollapse, { column: column.label })} onClick={() => toggle(column.id)}>
64
+ <FoundationIcon name={closed ? 'chevron-right' : 'chevron-down'} />
65
+ </button>
66
+ <h3>{column.label}</h3>
67
+ <span className='foundation-count'>{inColumn.length}{column.limit > 0 ? ` / ${column.limit}` : ''}</span>
68
+ {over && <span className='foundation-chip' data-tone='danger'><FoundationIcon name='alert' />{formatText(strings.boardWipExceeded, { limit: column.limit })}</span>}
69
+ </header>
70
+ {!closed && (
71
+ <ul className='foundation-board-cards'>
72
+ {inColumn.length === 0 && <li className='foundation-count'>{strings.boardNoCards}</li>}
73
+ {inColumn.map(card => (
74
+ <li key={card.id} className='foundation-board-card' draggable={!locked} onDragStart={() => setDragging(card.id)} onDragEnd={() => setDragging(undefined)}>
75
+ <strong>{card.title}</strong>
76
+ {card.assignee && <span className='foundation-count'><FoundationIcon name='user' /> {card.assignee}</span>}
77
+ {card.fields.map(field => <span key={field.label} className='foundation-board-field'><span className='foundation-count'>{field.label}</span> {field.value}</span>)}
78
+ {card.badges.length > 0 && <span className='foundation-board-badges'>{card.badges.map(badge => <span key={badge} className='foundation-chip' data-tone='neutral'>{badge}</span>)}</span>}
79
+ {!locked && (
80
+ <select className='foundation-board-move' aria-label={formatText(strings.boardMoveCard, { title: card.title })} value={column.id} onChange={event => move(card.id, event.target.value)}>
81
+ <option value={column.id} disabled>{strings.boardMoveTo}</option>
82
+ {columns.filter(other => other.id !== column.id).map(other => <option key={other.id} value={other.id}>{other.label}</option>)}
83
+ </select>
84
+ )}
85
+ </li>
86
+ ))}
87
+ </ul>
88
+ )}
89
+ </section>
90
+ );
91
+ })}
92
+ </div>
93
+ </>
94
+ )}
95
+ </FoundationFrame>
96
+ );
97
+ }
@@ -0,0 +1,28 @@
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 { RegisteredComponentProps } from '@cratis/scene.react';
5
+ import { FoundationIcon } from './FoundationIcon';
6
+ import { formatNumber, readLocale, NumberFormat } from './formatting';
7
+ import { formatText, readFoundationStrings } from './foundationStrings';
8
+
9
+ /**
10
+ * The `notificationsButton` component: the bell of the topbar, with the unread count.
11
+ *
12
+ * `count` is the unread number - bind it to a query - and the button says it in words ("3 unread") as well as in the badge, which is hidden from
13
+ * assistive technology so it is not read twice. Pressing it runs the behaviors attached to the element, which open whatever the application shows its
14
+ * notifications in. Counts above 99 read "99+" in the badge and in full in the words.
15
+ */
16
+ export function NotificationsButton({ element, interactions }: RegisteredComponentProps) {
17
+ const strings = readFoundationStrings(element);
18
+ const count = typeof element.properties.count === 'number' && element.properties.count > 0 ? Math.floor(element.properties.count) : 0;
19
+ const spoken = count > 0 ? formatText(strings.notificationsCount, { count: formatNumber(count, readLocale(element), NumberFormat.Number, undefined, 0) }) : strings.notificationsNone;
20
+
21
+ return (
22
+ <button type='button' className='foundation foundation-notifications' data-scene-id={element.id} onClick={interactions?.onClick}>
23
+ <FoundationIcon name='alert' />
24
+ <span className='foundation-visually-hidden'>{`${strings.notificationsLabel}: ${spoken}`}</span>
25
+ {count > 0 && <span className='foundation-notifications-badge' aria-hidden='true'>{count > 99 ? '99+' : count}</span>}
26
+ </button>
27
+ );
28
+ }