@cratis/scene.blueprint.default 4.38.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.
- package/ComponentName.ts +27 -0
- package/defaultBlueprintComponents.ts +10 -1
- package/dist/cjs/ComponentName.js +9 -0
- package/dist/cjs/ComponentName.js.map +1 -1
- package/dist/cjs/defaultBlueprintComponents.js +19 -1
- package/dist/cjs/defaultBlueprintComponents.js.map +1 -1
- package/dist/cjs/foundation/BulkBar.js +62 -0
- package/dist/cjs/foundation/BulkBar.js.map +1 -0
- package/dist/cjs/foundation/DataList.js +436 -0
- package/dist/cjs/foundation/DataList.js.map +1 -0
- package/dist/cjs/foundation/FormScreenBody.js +447 -0
- package/dist/cjs/foundation/FormScreenBody.js.map +1 -0
- package/dist/cjs/foundation/FoundationStates.js +2 -1
- package/dist/cjs/foundation/FoundationStates.js.map +1 -1
- package/dist/cjs/foundation/GlobalSearch.js +54 -0
- package/dist/cjs/foundation/GlobalSearch.js.map +1 -0
- package/dist/cjs/foundation/KanbanBoard.js +222 -0
- package/dist/cjs/foundation/KanbanBoard.js.map +1 -0
- package/dist/cjs/foundation/NotificationsButton.js +45 -0
- package/dist/cjs/foundation/NotificationsButton.js.map +1 -0
- package/dist/cjs/foundation/SubNavigation.js +72 -0
- package/dist/cjs/foundation/SubNavigation.js.map +1 -0
- package/dist/cjs/foundation/TabSet.js +101 -0
- package/dist/cjs/foundation/TabSet.js.map +1 -0
- package/dist/cjs/foundation/WizardProgress.js +185 -0
- package/dist/cjs/foundation/WizardProgress.js.map +1 -0
- package/dist/cjs/foundation/boardModel.js +67 -0
- package/dist/cjs/foundation/boardModel.js.map +1 -0
- package/dist/cjs/foundation/formModel.js +158 -0
- package/dist/cjs/foundation/formModel.js.map +1 -0
- package/dist/cjs/foundation/foundationComponentMetadata.js +54 -0
- package/dist/cjs/foundation/foundationComponentMetadata.js.map +1 -1
- package/dist/cjs/foundation/foundationDescriptors.js +400 -0
- package/dist/cjs/foundation/foundationDescriptors.js.map +1 -1
- package/dist/cjs/foundation/foundationSamples.js +303 -0
- package/dist/cjs/foundation/foundationSamples.js.map +1 -1
- package/dist/cjs/foundation/foundationStrings.js +72 -0
- package/dist/cjs/foundation/foundationStrings.js.map +1 -1
- package/dist/cjs/foundation/listModel.js +200 -0
- package/dist/cjs/foundation/listModel.js.map +1 -0
- package/dist/cjs/foundation/tabModel.js +41 -0
- package/dist/cjs/foundation/tabModel.js.map +1 -0
- package/dist/cjs/foundation/urlState.js +57 -0
- package/dist/cjs/foundation/urlState.js.map +1 -0
- package/dist/cjs/foundation/useCompactWidth.js +23 -0
- package/dist/cjs/foundation/useCompactWidth.js.map +1 -0
- package/dist/cjs/foundation/wizardModel.js +50 -0
- package/dist/cjs/foundation/wizardModel.js.map +1 -0
- package/dist/cjs/index.js +71 -0
- package/dist/cjs/index.js.map +1 -1
- package/dist/esm/ComponentName.d.ts +10 -1
- package/dist/esm/ComponentName.d.ts.map +1 -1
- package/dist/esm/ComponentName.js +9 -0
- package/dist/esm/ComponentName.js.map +1 -1
- package/dist/esm/defaultBlueprintComponents.d.ts.map +1 -1
- package/dist/esm/defaultBlueprintComponents.js +19 -1
- package/dist/esm/defaultBlueprintComponents.js.map +1 -1
- package/dist/esm/foundation/BulkBar.d.ts +4 -0
- package/dist/esm/foundation/BulkBar.d.ts.map +1 -0
- package/dist/esm/foundation/BulkBar.js +59 -0
- package/dist/esm/foundation/BulkBar.js.map +1 -0
- package/dist/esm/foundation/DataList.d.ts +3 -0
- package/dist/esm/foundation/DataList.d.ts.map +1 -0
- package/dist/esm/foundation/DataList.js +434 -0
- package/dist/esm/foundation/DataList.js.map +1 -0
- package/dist/esm/foundation/FormScreenBody.d.ts +3 -0
- package/dist/esm/foundation/FormScreenBody.d.ts.map +1 -0
- package/dist/esm/foundation/FormScreenBody.js +445 -0
- package/dist/esm/foundation/FormScreenBody.js.map +1 -0
- package/dist/esm/foundation/FoundationStates.js +2 -1
- package/dist/esm/foundation/FoundationStates.js.map +1 -1
- package/dist/esm/foundation/GlobalSearch.d.ts +3 -0
- package/dist/esm/foundation/GlobalSearch.d.ts.map +1 -0
- package/dist/esm/foundation/GlobalSearch.js +52 -0
- package/dist/esm/foundation/GlobalSearch.js.map +1 -0
- package/dist/esm/foundation/KanbanBoard.d.ts +3 -0
- package/dist/esm/foundation/KanbanBoard.d.ts.map +1 -0
- package/dist/esm/foundation/KanbanBoard.js +220 -0
- package/dist/esm/foundation/KanbanBoard.js.map +1 -0
- package/dist/esm/foundation/NotificationsButton.d.ts +3 -0
- package/dist/esm/foundation/NotificationsButton.d.ts.map +1 -0
- package/dist/esm/foundation/NotificationsButton.js +43 -0
- package/dist/esm/foundation/NotificationsButton.js.map +1 -0
- package/dist/esm/foundation/SubNavigation.d.ts +3 -0
- package/dist/esm/foundation/SubNavigation.d.ts.map +1 -0
- package/dist/esm/foundation/SubNavigation.js +70 -0
- package/dist/esm/foundation/SubNavigation.js.map +1 -0
- package/dist/esm/foundation/TabSet.d.ts +3 -0
- package/dist/esm/foundation/TabSet.d.ts.map +1 -0
- package/dist/esm/foundation/TabSet.js +99 -0
- package/dist/esm/foundation/TabSet.js.map +1 -0
- package/dist/esm/foundation/WizardProgress.d.ts +3 -0
- package/dist/esm/foundation/WizardProgress.d.ts.map +1 -0
- package/dist/esm/foundation/WizardProgress.js +183 -0
- package/dist/esm/foundation/WizardProgress.js.map +1 -0
- package/dist/esm/foundation/boardModel.d.ts +22 -0
- package/dist/esm/foundation/boardModel.d.ts.map +1 -0
- package/dist/esm/foundation/boardModel.js +61 -0
- package/dist/esm/foundation/boardModel.js.map +1 -0
- package/dist/esm/foundation/formModel.d.ts +48 -0
- package/dist/esm/foundation/formModel.d.ts.map +1 -0
- package/dist/esm/foundation/formModel.js +147 -0
- package/dist/esm/foundation/formModel.js.map +1 -0
- package/dist/esm/foundation/foundationComponentMetadata.d.ts.map +1 -1
- package/dist/esm/foundation/foundationComponentMetadata.js +54 -0
- package/dist/esm/foundation/foundationComponentMetadata.js.map +1 -1
- package/dist/esm/foundation/foundationDescriptors.d.ts.map +1 -1
- package/dist/esm/foundation/foundationDescriptors.js +400 -0
- package/dist/esm/foundation/foundationDescriptors.js.map +1 -1
- package/dist/esm/foundation/foundationSamples.d.ts.map +1 -1
- package/dist/esm/foundation/foundationSamples.js +303 -0
- package/dist/esm/foundation/foundationSamples.js.map +1 -1
- package/dist/esm/foundation/foundationStrings.d.ts +72 -0
- package/dist/esm/foundation/foundationStrings.d.ts.map +1 -1
- package/dist/esm/foundation/foundationStrings.js +72 -0
- package/dist/esm/foundation/foundationStrings.js.map +1 -1
- package/dist/esm/foundation/index.d.ts +16 -0
- package/dist/esm/foundation/index.d.ts.map +1 -1
- package/dist/esm/foundation/index.js +16 -0
- package/dist/esm/foundation/index.js.map +1 -1
- package/dist/esm/foundation/listModel.d.ts +49 -0
- package/dist/esm/foundation/listModel.d.ts.map +1 -0
- package/dist/esm/foundation/listModel.js +183 -0
- package/dist/esm/foundation/listModel.js.map +1 -0
- package/dist/esm/foundation/tabModel.d.ts +10 -0
- package/dist/esm/foundation/tabModel.d.ts.map +1 -0
- package/dist/esm/foundation/tabModel.js +37 -0
- package/dist/esm/foundation/tabModel.js.map +1 -0
- package/dist/esm/foundation/urlState.d.ts +4 -0
- package/dist/esm/foundation/urlState.d.ts.map +1 -0
- package/dist/esm/foundation/urlState.js +53 -0
- package/dist/esm/foundation/urlState.js.map +1 -0
- package/dist/esm/foundation/useCompactWidth.d.ts +3 -0
- package/dist/esm/foundation/useCompactWidth.d.ts.map +1 -0
- package/dist/esm/foundation/useCompactWidth.js +20 -0
- package/dist/esm/foundation/useCompactWidth.js.map +1 -0
- package/dist/esm/foundation/wizardModel.d.ts +16 -0
- package/dist/esm/foundation/wizardModel.d.ts.map +1 -0
- package/dist/esm/foundation/wizardModel.js +43 -0
- package/dist/esm/foundation/wizardModel.js.map +1 -0
- package/dist/esm/index.js +16 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/tsconfig.tsbuildinfo +1 -1
- package/foundation/BulkBar.tsx +38 -0
- package/foundation/DataList.tsx +208 -0
- package/foundation/FormScreenBody.tsx +196 -0
- package/foundation/FoundationStates.tsx +1 -1
- package/foundation/GlobalSearch.tsx +33 -0
- package/foundation/KanbanBoard.tsx +97 -0
- package/foundation/NotificationsButton.tsx +28 -0
- package/foundation/SubNavigation.tsx +49 -0
- package/foundation/TabSet.tsx +65 -0
- package/foundation/WizardProgress.tsx +109 -0
- package/foundation/boardModel.ts +64 -0
- package/foundation/formModel.ts +146 -0
- package/foundation/foundationComponentMetadata.ts +9 -0
- package/foundation/foundationDescriptors.ts +195 -0
- package/foundation/foundationSamples.ts +56 -0
- package/foundation/foundationStrings.ts +72 -0
- package/foundation/index.ts +16 -0
- package/foundation/listModel.ts +190 -0
- package/foundation/tabModel.ts +41 -0
- package/foundation/urlState.ts +52 -0
- package/foundation/useCompactWidth.ts +23 -0
- package/foundation/wizardModel.ts +54 -0
- package/package.json +8 -8
- package/shell/layout.css +513 -0
|
@@ -0,0 +1,49 @@
|
|
|
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, useValueOutput } from '@cratis/scene.react';
|
|
6
|
+
import { readOptionalString } from '../shell/elementProperties';
|
|
7
|
+
import { activeTab, readTabs } from './tabModel';
|
|
8
|
+
import { ComponentState, readComponentState } from './ComponentState';
|
|
9
|
+
import { readFoundationStrings } from './foundationStrings';
|
|
10
|
+
import { FoundationFrame } from './StateFrame';
|
|
11
|
+
import { useUrlState } from './urlState';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The `subNavigation` component: a module's own sections as a tab bar or a left rail.
|
|
15
|
+
*
|
|
16
|
+
* `items` name the sections (id, label, optional count badge), `orientation` is `horizontal` (tabs, default) or `vertical` (a left rail), and
|
|
17
|
+
* `urlKey` keeps the active section in the address so a deep link restores it. It is a navigation landmark, the current section carries
|
|
18
|
+
* `aria-current="page"`, and choosing a section only changes the section: the shell around it keeps its state, which is what makes it a
|
|
19
|
+
* sub-navigation rather than a page change. The id of the active section is the `value` output, which the module's content binds to.
|
|
20
|
+
*/
|
|
21
|
+
export function SubNavigation({ element, interactions, bindingOutputs }: RegisteredComponentProps) {
|
|
22
|
+
const items = useMemo(() => readTabs(element.properties.items), [element.properties.items]);
|
|
23
|
+
const [requested, setRequested] = useUrlState(readOptionalString(element, 'urlKey'), readOptionalString(element, 'activeItem'));
|
|
24
|
+
const active = activeTab(items, requested);
|
|
25
|
+
const strings = readFoundationStrings(element);
|
|
26
|
+
const state = readComponentState(element, items.length > 0);
|
|
27
|
+
|
|
28
|
+
useValueOutput(bindingOutputs, active);
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<FoundationFrame element={element} interactions={interactions} name='sub-navigation' state={state} skeleton={[50]}>
|
|
32
|
+
{state !== ComponentState.Loading && (
|
|
33
|
+
<nav aria-label={readOptionalString(element, 'label') ?? strings.subNavLabel} className='foundation-subnav' data-orientation={element.properties.orientation === 'vertical' ? 'vertical' : 'horizontal'}>
|
|
34
|
+
<ul>
|
|
35
|
+
{items.map(item => (
|
|
36
|
+
<li key={item.id}>
|
|
37
|
+
<a href={`?${new URLSearchParams({ [readOptionalString(element, 'urlKey') ?? 'section']: item.id }).toString()}`} aria-current={item.id === active ? 'page' : undefined}
|
|
38
|
+
aria-disabled={item.disabled || undefined} className='foundation-subnav-link'
|
|
39
|
+
onClick={event => { event.preventDefault(); if (!item.disabled) { setRequested(item.id); interactions?.onSelect?.(); } }}>
|
|
40
|
+
{item.label}{item.badge !== undefined && <span className='foundation-tab-badge'>{item.badge}</span>}
|
|
41
|
+
</a>
|
|
42
|
+
</li>
|
|
43
|
+
))}
|
|
44
|
+
</ul>
|
|
45
|
+
</nav>
|
|
46
|
+
)}
|
|
47
|
+
</FoundationFrame>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
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, useMemo, useRef } from 'react';
|
|
5
|
+
import { RegisteredComponentProps, useValueOutput } from '@cratis/scene.react';
|
|
6
|
+
import { readOptionalString } from '../shell/elementProperties';
|
|
7
|
+
import { activeTab, readTabs, stepTab } from './tabModel';
|
|
8
|
+
import { ComponentState, readComponentState } from './ComponentState';
|
|
9
|
+
import { readFoundationStrings } from './foundationStrings';
|
|
10
|
+
import { FoundationFrame } from './StateFrame';
|
|
11
|
+
import { useUrlState } from './urlState';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The `tabSet` component: a set of tabs, each showing the content filed under its id in the element's slots.
|
|
15
|
+
*
|
|
16
|
+
* `tabs` name the tabs (id, label, optional count badge, disabled); the content of tab `overview` is the slot `overview`. It follows the
|
|
17
|
+
* ARIA tabs pattern - arrows, Home and End move and select, the active tab is the one tab stop, each panel is labelled by its tab - and the
|
|
18
|
+
* active tab is kept in the address under `urlKey` when one is given, so a deep link and a refresh restore it. The id of the active tab is the
|
|
19
|
+
* `value` output. The tabs a record page offers (Overview, Related, Files, History) are configurable by changing this property.
|
|
20
|
+
*/
|
|
21
|
+
export function TabSet({ element, interactions, slots, bindingOutputs }: RegisteredComponentProps) {
|
|
22
|
+
const tabs = useMemo(() => readTabs(element.properties.tabs), [element.properties.tabs]);
|
|
23
|
+
const [requested, setRequested] = useUrlState(readOptionalString(element, 'urlKey'), readOptionalString(element, 'activeTab'));
|
|
24
|
+
const active = activeTab(tabs, requested);
|
|
25
|
+
const strings = readFoundationStrings(element);
|
|
26
|
+
const state = readComponentState(element, tabs.length > 0);
|
|
27
|
+
const list = useRef<HTMLDivElement>(null);
|
|
28
|
+
|
|
29
|
+
useValueOutput(bindingOutputs, active);
|
|
30
|
+
|
|
31
|
+
const choose = (id: string | undefined, focus = false) => {
|
|
32
|
+
if (id === undefined) return;
|
|
33
|
+
setRequested(id);
|
|
34
|
+
interactions?.onSelect?.();
|
|
35
|
+
if (focus) requestAnimationFrame(() => list.current?.querySelector<HTMLElement>(`[data-tab="${CSS.escape(id)}"]`)?.focus());
|
|
36
|
+
};
|
|
37
|
+
const move = (event: KeyboardEvent) => {
|
|
38
|
+
const direction = { ArrowRight: 'next', ArrowDown: 'next', ArrowLeft: 'previous', ArrowUp: 'previous', Home: 'first', End: 'last' }[event.key] as 'next' | 'previous' | 'first' | 'last' | undefined;
|
|
39
|
+
if (direction === undefined) return;
|
|
40
|
+
event.preventDefault();
|
|
41
|
+
choose(stepTab(tabs, active, direction), true);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<FoundationFrame element={element} interactions={interactions} name='tab-set' state={state} skeleton={[60, 100]}>
|
|
46
|
+
{state !== ComponentState.Loading && (
|
|
47
|
+
<>
|
|
48
|
+
<div ref={list} role='tablist' aria-label={readOptionalString(element, 'label') ?? strings.tabsLabel} className='foundation-tabs' onKeyDown={move}>
|
|
49
|
+
{tabs.map(tab => (
|
|
50
|
+
<button key={tab.id} id={`${element.id}-tab-${tab.id}`} data-tab={tab.id} type='button' role='tab' className='foundation-tab' aria-selected={tab.id === active}
|
|
51
|
+
aria-controls={`${element.id}-panel-${tab.id}`} tabIndex={tab.id === active ? 0 : -1} disabled={tab.disabled} onClick={() => choose(tab.id)}>
|
|
52
|
+
{tab.label}{tab.badge !== undefined && <span className='foundation-tab-badge'>{tab.badge}</span>}
|
|
53
|
+
</button>
|
|
54
|
+
))}
|
|
55
|
+
</div>
|
|
56
|
+
{tabs.map(tab => (
|
|
57
|
+
<div key={tab.id} id={`${element.id}-panel-${tab.id}`} role='tabpanel' aria-labelledby={`${element.id}-tab-${tab.id}`} hidden={tab.id !== active} tabIndex={0} className='foundation-tab-panel'>
|
|
58
|
+
{tab.id === active ? slots?.[tab.id] : undefined}
|
|
59
|
+
</div>
|
|
60
|
+
))}
|
|
61
|
+
</>
|
|
62
|
+
)}
|
|
63
|
+
</FoundationFrame>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
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 { formatText, readFoundationStrings } from './foundationStrings';
|
|
10
|
+
import { FoundationFrame } from './StateFrame';
|
|
11
|
+
import { StepStatus, canOpen, neighbour, readWizardSteps, reachableStep, statusOf } from './wizardModel';
|
|
12
|
+
import { ActionButton } from './ActionButton';
|
|
13
|
+
import { useUrlState } from './urlState';
|
|
14
|
+
|
|
15
|
+
const storageKey = (element: { id: string }, key: string | undefined) => `scene.wizard.${key ?? element.id}`;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The completed steps saved in the session, or none. Storage can be absent or refuse access - server rendering, a sandboxed frame, a browser in a
|
|
19
|
+
* private mode - and a wizard that cannot remember its progress is still a wizard, so every storage access is guarded and a failure only loses the memory.
|
|
20
|
+
*/
|
|
21
|
+
function readSaved(key: string): Set<string> {
|
|
22
|
+
try {
|
|
23
|
+
const saved = JSON.parse(window.sessionStorage.getItem(key) ?? '[]') as unknown;
|
|
24
|
+
return new Set(Array.isArray(saved) ? saved.filter((id): id is string => typeof id === 'string') : []);
|
|
25
|
+
} catch {
|
|
26
|
+
return new Set();
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Saves the completed steps for the session; a refusal by the browser is ignored (see {@link readSaved}). */
|
|
31
|
+
function writeSaved(key: string, completed: Set<string>): void {
|
|
32
|
+
try {
|
|
33
|
+
window.sessionStorage.setItem(key, JSON.stringify([...completed]));
|
|
34
|
+
} catch {
|
|
35
|
+
// The progress is simply not remembered across a refresh.
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The `wizardProgress` component: the steps of a linear wizard, the back/next/finish controls and the draft save, with the step's own content in
|
|
41
|
+
* the slot named by its id.
|
|
42
|
+
*
|
|
43
|
+
* `steps` (`id`, `label`, `valid`) are linear. Next is blocked while the current step is not `valid`, and says so. The final step is the review step: Finish
|
|
44
|
+
* replaces Next. Completed steps stay in a progress list and can be revisited; a step cannot be opened before the ones ahead of it are done. The current step
|
|
45
|
+
* (in the address under `urlKey`) and the completed steps (in the session) are restored on refresh, and a refresh can never land past a step that was not
|
|
46
|
+
* done. The id of the current step is the `value` output; "Save draft" runs the behaviors attached to the element and says so.
|
|
47
|
+
*/
|
|
48
|
+
export function WizardProgress({ element, interactions, slots, bindingOutputs }: RegisteredComponentProps) {
|
|
49
|
+
const steps = useMemo(() => readWizardSteps(element.properties.steps), [element.properties.steps]);
|
|
50
|
+
const urlKey = readOptionalString(element, 'urlKey');
|
|
51
|
+
const [requested, setRequested] = useUrlState(urlKey, readOptionalString(element, 'currentStep'));
|
|
52
|
+
const key = storageKey(element, urlKey);
|
|
53
|
+
const [completed, setCompleted] = useState<Set<string>>(() => readSaved(key));
|
|
54
|
+
const [draftSaved, setDraftSaved] = useState(false);
|
|
55
|
+
const strings = readFoundationStrings(element);
|
|
56
|
+
const state = readComponentState(element, steps.length > 0);
|
|
57
|
+
const current = reachableStep(steps, completed, requested);
|
|
58
|
+
const index = steps.findIndex(step => step.id === current);
|
|
59
|
+
const step = steps[index];
|
|
60
|
+
const last = index === steps.length - 1;
|
|
61
|
+
|
|
62
|
+
useEffect(() => writeSaved(key, completed), [key, completed]);
|
|
63
|
+
useValueOutput(bindingOutputs, current);
|
|
64
|
+
|
|
65
|
+
const go = (id: string | undefined) => { if (id === undefined) return; setRequested(id); setDraftSaved(false); interactions?.onSelect?.(); };
|
|
66
|
+
const next = () => {
|
|
67
|
+
if (!step?.valid) return;
|
|
68
|
+
setCompleted(current_ => new Set(current_).add(step.id));
|
|
69
|
+
go(neighbour(steps, current, 1)?.id);
|
|
70
|
+
};
|
|
71
|
+
const finish = () => { if (!step?.valid) return; setCompleted(current_ => new Set(current_).add(step.id)); interactions?.onClick?.({ stopPropagation: () => undefined }); };
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<FoundationFrame element={element} interactions={interactions} name='wizard' state={state} skeleton={[100, 60, 100]}>
|
|
75
|
+
{state !== ComponentState.Loading && step && (
|
|
76
|
+
<>
|
|
77
|
+
<nav aria-label={strings.wizardProgress}>
|
|
78
|
+
<ol className='foundation-wizard-steps'>
|
|
79
|
+
{steps.map((candidate, position) => {
|
|
80
|
+
const status = statusOf(completed, current, candidate.id);
|
|
81
|
+
const open = canOpen(steps, completed, candidate.id);
|
|
82
|
+
const text = status === StepStatus.Completed ? strings.wizardComplete : status === StepStatus.Current ? strings.wizardCurrent : strings.wizardUpcoming;
|
|
83
|
+
return (
|
|
84
|
+
<li key={candidate.id} className='foundation-wizard-step' data-status={status}>
|
|
85
|
+
<button type='button' aria-current={status === StepStatus.Current ? 'step' : undefined} disabled={!open} onClick={() => go(candidate.id)}>
|
|
86
|
+
<span className='foundation-wizard-marker'>{status === StepStatus.Completed ? <FoundationIcon name='check' /> : position + 1}</span>
|
|
87
|
+
<span>{candidate.label}</span>
|
|
88
|
+
<span className='foundation-visually-hidden'>{`, ${formatText(strings.wizardStep, { n: position + 1, total: steps.length })}, ${text}`}</span>
|
|
89
|
+
</button>
|
|
90
|
+
</li>
|
|
91
|
+
);
|
|
92
|
+
})}
|
|
93
|
+
</ol>
|
|
94
|
+
</nav>
|
|
95
|
+
<div className='foundation-wizard-body' role='group' aria-label={`${formatText(strings.wizardStep, { n: index + 1, total: steps.length })}: ${step.label}`}>
|
|
96
|
+
{slots?.[step.id]}
|
|
97
|
+
</div>
|
|
98
|
+
<div className='foundation-wizard-actions'>
|
|
99
|
+
<ActionButton emphasis='quiet' disabledReason={strings.disabledNoPermission} onClick={() => { setDraftSaved(true); interactions?.onClick?.({ stopPropagation: () => undefined }); }}>{strings.wizardSaveDraft}</ActionButton>
|
|
100
|
+
<span role='status' className='foundation-count'>{draftSaved ? strings.wizardDraftSaved : ''}</span>
|
|
101
|
+
<span className='foundation-wizard-spacer' />
|
|
102
|
+
{index > 0 && <ActionButton emphasis='secondary' disabledReason={strings.disabledNoPermission} onClick={() => go(neighbour(steps, current, -1)?.id)}>{strings.wizardBack}</ActionButton>}
|
|
103
|
+
<button type='button' className='foundation-button' aria-disabled={!step.valid || undefined} onClick={last ? finish : next}>{last ? strings.wizardFinish : strings.wizardNext}</button>
|
|
104
|
+
</div>
|
|
105
|
+
</>
|
|
106
|
+
)}
|
|
107
|
+
</FoundationFrame>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
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
|
+
/** One column of a board, mapped to a status or stage value. */
|
|
5
|
+
export interface BoardColumn {
|
|
6
|
+
id: string;
|
|
7
|
+
label: string;
|
|
8
|
+
/** The most cards the stage should hold; 0 means no limit. */
|
|
9
|
+
limit: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** One card: the title, an assignee, a few key fields and badges. */
|
|
13
|
+
export interface BoardCard {
|
|
14
|
+
id: string;
|
|
15
|
+
title: string;
|
|
16
|
+
column: string;
|
|
17
|
+
assignee?: string;
|
|
18
|
+
fields: { label: string; value: string }[];
|
|
19
|
+
badges: string[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const records = (value: unknown): Record<string, unknown>[] =>
|
|
23
|
+
Array.isArray(value) ? value.filter((entry): entry is Record<string, unknown> => typeof entry === 'object' && entry !== null && !Array.isArray(entry)) : [];
|
|
24
|
+
|
|
25
|
+
/** Reads columns, dropping those without an id and repeated ids. */
|
|
26
|
+
export function readColumnsOfBoard(value: unknown): BoardColumn[] {
|
|
27
|
+
const seen = new Set<string>();
|
|
28
|
+
return records(value).flatMap(entry => {
|
|
29
|
+
if (typeof entry.id !== 'string' || entry.id.length === 0 || seen.has(entry.id)) return [];
|
|
30
|
+
seen.add(entry.id);
|
|
31
|
+
return [{ id: entry.id, label: typeof entry.label === 'string' && entry.label.length > 0 ? entry.label : entry.id, limit: typeof entry.limit === 'number' && entry.limit > 0 ? Math.floor(entry.limit) : 0 }];
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Reads cards, dropping those without an id or a title, keeping at most three key fields. */
|
|
36
|
+
export function readCards(value: unknown): BoardCard[] {
|
|
37
|
+
const seen = new Set<string>();
|
|
38
|
+
return records(value).flatMap(entry => {
|
|
39
|
+
if (typeof entry.id !== 'string' || entry.id.length === 0 || seen.has(entry.id) || typeof entry.title !== 'string') return [];
|
|
40
|
+
seen.add(entry.id);
|
|
41
|
+
const fields = records(entry.fields).flatMap(field => (typeof field.label === 'string' && typeof field.value === 'string' ? [{ label: field.label, value: field.value }] : [])).slice(0, 3);
|
|
42
|
+
return [{
|
|
43
|
+
id: entry.id, title: entry.title, column: typeof entry.column === 'string' ? entry.column : '', fields,
|
|
44
|
+
badges: Array.isArray(entry.badges) ? entry.badges.filter((badge): badge is string => typeof badge === 'string') : [],
|
|
45
|
+
...(typeof entry.assignee === 'string' ? { assignee: entry.assignee } : {}),
|
|
46
|
+
}];
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The cards of one column, in the order given. A card whose column does not exist is shown in the first column rather than lost. */
|
|
51
|
+
export function cardsOf(columns: BoardColumn[], cards: BoardCard[], column: string): BoardCard[] {
|
|
52
|
+
const known = new Set(columns.map(candidate => candidate.id));
|
|
53
|
+
return cards.filter(card => card.column === column || (column === columns[0]?.id && !known.has(card.column)));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Moves a card to a column, at the end; moving to the column it is in, or to one that does not exist, changes nothing. */
|
|
57
|
+
export function moveCard(columns: BoardColumn[], cards: BoardCard[], id: string, to: string): BoardCard[] {
|
|
58
|
+
const card = cards.find(candidate => candidate.id === id);
|
|
59
|
+
if (!card || !columns.some(column => column.id === to) || cardsOf(columns, cards, to).includes(card)) return cards;
|
|
60
|
+
return [...cards.filter(candidate => candidate.id !== id), { ...card, column: to }];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Whether a column holds more cards than its limit. */
|
|
64
|
+
export const overLimit = (column: BoardColumn, count: number): boolean => column.limit > 0 && count > column.limit;
|
|
@@ -0,0 +1,146 @@
|
|
|
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
|
+
/** The field types a form offers (Part B 2.8). */
|
|
5
|
+
export enum FormFieldType {
|
|
6
|
+
Text = 'text',
|
|
7
|
+
Number = 'number',
|
|
8
|
+
Currency = 'currency',
|
|
9
|
+
Date = 'date',
|
|
10
|
+
Select = 'select',
|
|
11
|
+
MultiSelect = 'multiSelect',
|
|
12
|
+
Lookup = 'lookup',
|
|
13
|
+
Toggle = 'toggle',
|
|
14
|
+
File = 'file',
|
|
15
|
+
RichText = 'richText',
|
|
16
|
+
Address = 'address',
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** One field of a form section. */
|
|
20
|
+
export interface FormField {
|
|
21
|
+
name: string;
|
|
22
|
+
label: string;
|
|
23
|
+
type: FormFieldType;
|
|
24
|
+
required: boolean;
|
|
25
|
+
options: string[];
|
|
26
|
+
/** The columns of the section grid the field spans (1 to 3). */
|
|
27
|
+
span: number;
|
|
28
|
+
placeholder?: string;
|
|
29
|
+
help?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A titled group of fields in a grid of one to three columns. */
|
|
33
|
+
export interface FormSection {
|
|
34
|
+
id: string;
|
|
35
|
+
title: string;
|
|
36
|
+
columns: number;
|
|
37
|
+
fields: FormField[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The values of a form by field name; a multi-select is an array, a toggle a boolean, an address an object of lines. */
|
|
41
|
+
export type FormValues = Record<string, unknown>;
|
|
42
|
+
|
|
43
|
+
/** The ways the one form can be used (Part B 2.8). */
|
|
44
|
+
export enum FormMode {
|
|
45
|
+
Create = 'create',
|
|
46
|
+
Edit = 'edit',
|
|
47
|
+
ReadOnly = 'readOnly',
|
|
48
|
+
Settings = 'settings',
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The lines of an address, in the order they are asked. */
|
|
52
|
+
export const addressLines = ['street', 'postalCode', 'city', 'country'] as const;
|
|
53
|
+
|
|
54
|
+
const records = (value: unknown): Record<string, unknown>[] =>
|
|
55
|
+
Array.isArray(value) ? value.filter((entry): entry is Record<string, unknown> => typeof entry === 'object' && entry !== null && !Array.isArray(entry)) : [];
|
|
56
|
+
|
|
57
|
+
/** Reads the mode, defaulting to create. */
|
|
58
|
+
export const readFormMode = (value: unknown): FormMode => ((Object.values(FormMode) as string[]).includes(value as string) ? (value as FormMode) : FormMode.Create);
|
|
59
|
+
|
|
60
|
+
/** Reads sections, with their fields, dropping fields without a name and repeated names; a section grid is clamped to one to three columns. */
|
|
61
|
+
export function readFormSections(value: unknown): FormSection[] {
|
|
62
|
+
const names = new Set<string>();
|
|
63
|
+
return records(value).flatMap((section, index) => {
|
|
64
|
+
const columns = typeof section.columns === 'number' ? Math.min(3, Math.max(1, Math.floor(section.columns))) : 1;
|
|
65
|
+
const fields = records(section.fields).flatMap((entry): FormField[] => {
|
|
66
|
+
if (typeof entry.name !== 'string' || entry.name.length === 0 || names.has(entry.name)) return [];
|
|
67
|
+
names.add(entry.name);
|
|
68
|
+
return [{
|
|
69
|
+
name: entry.name,
|
|
70
|
+
label: typeof entry.label === 'string' && entry.label.length > 0 ? entry.label : entry.name,
|
|
71
|
+
type: (Object.values(FormFieldType) as string[]).includes(entry.type as string) ? (entry.type as FormFieldType) : FormFieldType.Text,
|
|
72
|
+
required: entry.required === true,
|
|
73
|
+
options: Array.isArray(entry.options) ? entry.options.filter((option): option is string => typeof option === 'string') : [],
|
|
74
|
+
span: typeof entry.span === 'number' ? Math.min(columns, Math.max(1, Math.floor(entry.span))) : 1,
|
|
75
|
+
...(typeof entry.placeholder === 'string' ? { placeholder: entry.placeholder } : {}),
|
|
76
|
+
...(typeof entry.help === 'string' ? { help: entry.help } : {}),
|
|
77
|
+
}];
|
|
78
|
+
});
|
|
79
|
+
return [{ id: typeof section.id === 'string' && section.id.length > 0 ? section.id : `section-${index}`, title: typeof section.title === 'string' ? section.title : '', columns, fields }];
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Whether a field holds a value: an empty or blank text, an empty list, a toggle left false (a required toggle must be on) or an address with no street and city are empty. */
|
|
84
|
+
export function isFilled(field: FormField, value: unknown): boolean {
|
|
85
|
+
switch (field.type) {
|
|
86
|
+
case FormFieldType.Toggle: return value === true;
|
|
87
|
+
case FormFieldType.MultiSelect: return Array.isArray(value) && value.length > 0;
|
|
88
|
+
case FormFieldType.File: return Array.isArray(value) ? value.length > 0 : typeof value === 'string' && value.trim().length > 0;
|
|
89
|
+
case FormFieldType.Address: return typeof value === 'object' && value !== null && ['street', 'city'].every(line => typeof (value as Record<string, unknown>)[line] === 'string' && ((value as Record<string, unknown>)[line] as string).trim().length > 0);
|
|
90
|
+
case FormFieldType.Number: case FormFieldType.Currency: return typeof value === 'number' ? Number.isFinite(value) : typeof value === 'string' && value.trim().length > 0 && Number.isFinite(Number(value));
|
|
91
|
+
default: return typeof value === 'string' ? value.trim().length > 0 : value !== undefined && value !== null && value !== '';
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Why a field is invalid, as a kind the component words in the locale: `required`, `invalid`, or undefined when it is fine. An empty optional field is fine. */
|
|
96
|
+
export function problemOf(field: FormField, value: unknown): 'required' | 'invalid' | undefined {
|
|
97
|
+
if (!isFilled(field, value)) {
|
|
98
|
+
const empty = value === undefined || value === null || value === '' || (typeof value === 'string' && value.trim() === '') || (Array.isArray(value) && value.length === 0) || value === false;
|
|
99
|
+
return field.required ? 'required' : empty ? undefined : 'invalid';
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (field.type === FormFieldType.Date && typeof value === 'string' && Number.isNaN(new Date(value).getTime())) return 'invalid';
|
|
103
|
+
if ((field.type === FormFieldType.Select || field.type === FormFieldType.Lookup) && field.options.length > 0 && typeof value === 'string' && !field.options.includes(value)) return 'invalid';
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The problems of every field, by field name, for the fields in the given sections. */
|
|
108
|
+
export function validate(sections: FormSection[], values: FormValues): Record<string, 'required' | 'invalid'> {
|
|
109
|
+
const problems: Record<string, 'required' | 'invalid'> = {};
|
|
110
|
+
for (const field of sections.flatMap(section => section.fields)) {
|
|
111
|
+
const problem = problemOf(field, values[field.name]);
|
|
112
|
+
if (problem) problems[field.name] = problem;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return problems;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Whether two sets of values are the same, field by field and in depth, so the unsaved-changes guard compares what the person sees. */
|
|
119
|
+
export function sameValues(left: FormValues, right: FormValues): boolean {
|
|
120
|
+
const keys = new Set([...Object.keys(left), ...Object.keys(right)]);
|
|
121
|
+
return [...keys].every(key => JSON.stringify(normalizeValue(left[key])) === JSON.stringify(normalizeValue(right[key])));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function normalizeValue(value: unknown): unknown {
|
|
125
|
+
if (value === undefined || value === null || value === '' || value === false) return null;
|
|
126
|
+
if (Array.isArray(value)) return value.length === 0 ? null : value;
|
|
127
|
+
if (typeof value === 'object') { const entries = Object.entries(value as Record<string, unknown>).filter(([, line]) => line !== '' && line !== undefined).sort(([a], [b]) => a.localeCompare(b)); return entries.length === 0 ? null : entries; }
|
|
128
|
+
return value;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Maps a server's field errors (`{ field: message }` or `[{ field, message }]`) onto the fields of the form; an error for a field the form does not have is returned as a general one so it is never lost. */
|
|
132
|
+
export function mapServerErrors(sections: FormSection[], errors: unknown): { byField: Record<string, string>; general: string[] } {
|
|
133
|
+
const known = new Set(sections.flatMap(section => section.fields.map(field => field.name)));
|
|
134
|
+
const pairs: [string, string][] = Array.isArray(errors)
|
|
135
|
+
? records(errors).flatMap(entry => (typeof entry.message === 'string' ? [[typeof entry.field === 'string' ? entry.field : '', entry.message] as [string, string]] : []))
|
|
136
|
+
: typeof errors === 'object' && errors !== null ? Object.entries(errors as Record<string, unknown>).flatMap(([field, message]) => (typeof message === 'string' ? [[field, message] as [string, string]] : [])) : [];
|
|
137
|
+
|
|
138
|
+
const byField: Record<string, string> = {};
|
|
139
|
+
const general: string[] = [];
|
|
140
|
+
for (const [field, message] of pairs) {
|
|
141
|
+
if (known.has(field) && byField[field] === undefined) byField[field] = message;
|
|
142
|
+
else general.push(field && !known.has(field) ? `${field}: ${message}` : message);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return { byField, general };
|
|
146
|
+
}
|
|
@@ -25,4 +25,13 @@ export const foundationComponentMetadata: Record<string, ComponentMetadata> = {
|
|
|
25
25
|
[ComponentName.ApprovalChainStepper]: m('Approval chain', ComponentCategory.Navigation, 'workflow', ['approvals', 'steps', 'workflow', 'sign off', 'stepper']),
|
|
26
26
|
[ComponentName.OrgTree]: m('Org tree', ComponentCategory.DataDisplay, 'network', ['organization', 'hierarchy', 'org chart', 'reports to', 'tree']),
|
|
27
27
|
[ComponentName.PermissionMatrix]: m('Permission matrix', ComponentCategory.Input, 'shield-check', ['roles', 'permissions', 'access', 'grants', 'rbac']),
|
|
28
|
+
[ComponentName.DataList]: m('Data list', ComponentCategory.DataDisplay, 'table', ['table', 'grid', 'records', 'cards', 'collection', 'list']),
|
|
29
|
+
[ComponentName.TabSet]: m('Tab set', ComponentCategory.Navigation, 'panel-top', ['tabs', 'sections', 'panels']),
|
|
30
|
+
[ComponentName.SubNavigation]: m('Sub-navigation', ComponentCategory.Navigation, 'panel-left', ['module', 'sections', 'rail', 'tabs']),
|
|
31
|
+
[ComponentName.BulkBar]: m('Bulk bar', ComponentCategory.Actions, 'check-check', ['selection', 'bulk', 'batch', 'actions']),
|
|
32
|
+
[ComponentName.GlobalSearch]: m('Search', ComponentCategory.Input, 'search', ['search', 'find', 'query']),
|
|
33
|
+
[ComponentName.NotificationsButton]: m('Notifications button', ComponentCategory.Actions, 'bell', ['bell', 'alerts', 'unread']),
|
|
34
|
+
[ComponentName.KanbanBoard]: m('Kanban board', ComponentCategory.DataDisplay, 'kanban', ['board', 'kanban', 'columns', 'pipeline', 'cards']),
|
|
35
|
+
[ComponentName.WizardProgress]: m('Wizard', ComponentCategory.Navigation, 'footprints', ['wizard', 'steps', 'stepper', 'multi-step']),
|
|
36
|
+
[ComponentName.FormScreenBody]: m('Form', ComponentCategory.Forms, 'text-cursor-input', ['form', 'fields', 'sections', 'validation']),
|
|
28
37
|
};
|