@cratis/scene.blueprint.default 4.41.0 → 4.42.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 +9 -0
- package/defaultBlueprintComponents.ts +4 -1
- package/dist/cjs/ComponentName.js +3 -0
- package/dist/cjs/ComponentName.js.map +1 -1
- package/dist/cjs/defaultBlueprint.js +2 -0
- package/dist/cjs/defaultBlueprint.js.map +1 -1
- package/dist/cjs/defaultBlueprintComponentMetadata.js +2 -0
- package/dist/cjs/defaultBlueprintComponentMetadata.js.map +1 -1
- package/dist/cjs/defaultBlueprintComponents.js +8 -0
- package/dist/cjs/defaultBlueprintComponents.js.map +1 -1
- package/dist/cjs/foundation/ActionButton.js.map +1 -1
- package/dist/cjs/foundation/ActionButtonControl.js +46 -0
- package/dist/cjs/foundation/ActionButtonControl.js.map +1 -0
- package/dist/cjs/foundation/DialogFrame.js +196 -0
- package/dist/cjs/foundation/DialogFrame.js.map +1 -0
- package/dist/cjs/foundation/FormScreenBody.js +13 -3
- package/dist/cjs/foundation/FormScreenBody.js.map +1 -1
- package/dist/cjs/foundation/Notice.js +51 -0
- package/dist/cjs/foundation/Notice.js.map +1 -0
- package/dist/cjs/foundation/formModel.js +1 -0
- package/dist/cjs/foundation/formModel.js.map +1 -1
- package/dist/cjs/foundation/foundationComponentMetadata.js +22 -0
- package/dist/cjs/foundation/foundationComponentMetadata.js.map +1 -1
- package/dist/cjs/foundation/foundationDescriptors.js +89 -0
- package/dist/cjs/foundation/foundationDescriptors.js.map +1 -1
- package/dist/cjs/foundation/foundationSamples.js +29 -0
- package/dist/cjs/foundation/foundationSamples.js.map +1 -1
- package/dist/cjs/foundation/foundationStrings.js +3 -1
- package/dist/cjs/foundation/foundationStrings.js.map +1 -1
- package/dist/cjs/foundation/templates/dialogTemplates.js +254 -0
- package/dist/cjs/foundation/templates/dialogTemplates.js.map +1 -0
- package/dist/cjs/foundation/templates/index.js +18 -0
- package/dist/cjs/foundation/templates/index.js.map +1 -1
- package/dist/cjs/foundation/templates/stateScreenTemplates.js +252 -0
- package/dist/cjs/foundation/templates/stateScreenTemplates.js.map +1 -0
- package/dist/cjs/gallery/GalleryDialogPreview.js +62 -0
- package/dist/cjs/gallery/GalleryDialogPreview.js.map +1 -0
- package/dist/cjs/gallery/GalleryScreenPreview.js +2 -0
- package/dist/cjs/gallery/GalleryScreenPreview.js.map +1 -1
- package/dist/cjs/gallery/dialogTemplates.js +41 -36
- package/dist/cjs/gallery/dialogTemplates.js.map +1 -1
- package/dist/cjs/gallery/screens.js +4 -1
- package/dist/cjs/gallery/screens.js.map +1 -1
- package/dist/cjs/index.js +36 -5
- package/dist/cjs/index.js.map +1 -1
- package/dist/esm/ComponentName.d.ts +3 -0
- package/dist/esm/ComponentName.d.ts.map +1 -1
- package/dist/esm/ComponentName.js +3 -0
- package/dist/esm/ComponentName.js.map +1 -1
- package/dist/esm/defaultBlueprint.js +2 -0
- package/dist/esm/defaultBlueprint.js.map +1 -1
- package/dist/esm/defaultBlueprintComponentMetadata.js +2 -0
- package/dist/esm/defaultBlueprintComponentMetadata.js.map +1 -1
- package/dist/esm/defaultBlueprintComponents.d.ts.map +1 -1
- package/dist/esm/defaultBlueprintComponents.js +8 -0
- package/dist/esm/defaultBlueprintComponents.js.map +1 -1
- package/dist/esm/foundation/ActionButton.d.ts +1 -1
- package/dist/esm/foundation/ActionButton.d.ts.map +1 -1
- package/dist/esm/foundation/ActionButton.js.map +1 -1
- package/dist/esm/foundation/ActionButtonControl.d.ts +3 -0
- package/dist/esm/foundation/ActionButtonControl.d.ts.map +1 -0
- package/dist/esm/foundation/ActionButtonControl.js +44 -0
- package/dist/esm/foundation/ActionButtonControl.js.map +1 -0
- package/dist/esm/foundation/DialogFrame.d.ts +14 -0
- package/dist/esm/foundation/DialogFrame.d.ts.map +1 -0
- package/dist/esm/foundation/DialogFrame.js +190 -0
- package/dist/esm/foundation/DialogFrame.js.map +1 -0
- package/dist/esm/foundation/FormScreenBody.d.ts.map +1 -1
- package/dist/esm/foundation/FormScreenBody.js +13 -3
- package/dist/esm/foundation/FormScreenBody.js.map +1 -1
- package/dist/esm/foundation/Notice.d.ts +3 -0
- package/dist/esm/foundation/Notice.d.ts.map +1 -0
- package/dist/esm/foundation/Notice.js +49 -0
- package/dist/esm/foundation/Notice.js.map +1 -0
- package/dist/esm/foundation/formModel.d.ts +2 -1
- package/dist/esm/foundation/formModel.d.ts.map +1 -1
- package/dist/esm/foundation/formModel.js +1 -0
- package/dist/esm/foundation/formModel.js.map +1 -1
- package/dist/esm/foundation/foundationComponentMetadata.d.ts.map +1 -1
- package/dist/esm/foundation/foundationComponentMetadata.js +22 -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 +89 -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 +29 -0
- package/dist/esm/foundation/foundationSamples.js.map +1 -1
- package/dist/esm/foundation/foundationStrings.d.ts +2 -0
- package/dist/esm/foundation/foundationStrings.d.ts.map +1 -1
- package/dist/esm/foundation/foundationStrings.js +3 -1
- package/dist/esm/foundation/foundationStrings.js.map +1 -1
- package/dist/esm/foundation/index.d.ts +3 -0
- package/dist/esm/foundation/index.d.ts.map +1 -1
- package/dist/esm/foundation/index.js +3 -0
- package/dist/esm/foundation/index.js.map +1 -1
- package/dist/esm/foundation/templates/dialogTemplates.d.ts +20 -0
- package/dist/esm/foundation/templates/dialogTemplates.d.ts.map +1 -0
- package/dist/esm/foundation/templates/dialogTemplates.js +243 -0
- package/dist/esm/foundation/templates/dialogTemplates.js.map +1 -0
- package/dist/esm/foundation/templates/index.d.ts +2 -0
- package/dist/esm/foundation/templates/index.d.ts.map +1 -1
- package/dist/esm/foundation/templates/index.js +2 -0
- package/dist/esm/foundation/templates/index.js.map +1 -1
- package/dist/esm/foundation/templates/stateScreenTemplates.d.ts +8 -0
- package/dist/esm/foundation/templates/stateScreenTemplates.d.ts.map +1 -0
- package/dist/esm/foundation/templates/stateScreenTemplates.js +245 -0
- package/dist/esm/foundation/templates/stateScreenTemplates.js.map +1 -0
- package/dist/esm/gallery/FoundationDialogs.stories.d.ts +35 -0
- package/dist/esm/gallery/FoundationDialogs.stories.d.ts.map +1 -0
- package/dist/esm/gallery/FoundationDialogs.stories.js +38 -0
- package/dist/esm/gallery/FoundationDialogs.stories.js.map +1 -0
- package/dist/esm/gallery/GalleryDialogPreview.d.ts +17 -0
- package/dist/esm/gallery/GalleryDialogPreview.d.ts.map +1 -0
- package/dist/esm/gallery/GalleryDialogPreview.js +59 -0
- package/dist/esm/gallery/GalleryDialogPreview.js.map +1 -0
- package/dist/esm/gallery/GalleryScreenPreview.js +2 -0
- package/dist/esm/gallery/GalleryScreenPreview.js.map +1 -1
- package/dist/esm/gallery/dialogTemplates.d.ts.map +1 -1
- package/dist/esm/gallery/dialogTemplates.js +42 -37
- package/dist/esm/gallery/dialogTemplates.js.map +1 -1
- package/dist/esm/gallery/index.d.ts +1 -0
- package/dist/esm/gallery/index.d.ts.map +1 -1
- package/dist/esm/gallery/index.js +1 -0
- package/dist/esm/gallery/index.js.map +1 -1
- package/dist/esm/gallery/screens.d.ts.map +1 -1
- package/dist/esm/gallery/screens.js +4 -1
- package/dist/esm/gallery/screens.js.map +1 -1
- package/dist/esm/index.js +6 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/tsconfig.tsbuildinfo +1 -1
- package/foundation/ActionButton.tsx +1 -1
- package/foundation/ActionButtonControl.tsx +37 -0
- package/foundation/DialogFrame.tsx +138 -0
- package/foundation/FormScreenBody.tsx +6 -3
- package/foundation/Notice.tsx +33 -0
- package/foundation/formModel.ts +1 -0
- package/foundation/foundationComponentMetadata.ts +3 -0
- package/foundation/foundationDescriptors.ts +41 -0
- package/foundation/foundationSamples.ts +3 -0
- package/foundation/foundationStrings.ts +2 -0
- package/foundation/index.ts +3 -0
- package/foundation/templates/dialogTemplates.ts +114 -0
- package/foundation/templates/index.ts +2 -0
- package/foundation/templates/stateScreenTemplates.ts +107 -0
- package/gallery/FoundationDialogs.stories.tsx +74 -0
- package/gallery/GalleryDialogPreview.tsx +68 -0
- package/gallery/dialogTemplates.ts +22 -17
- package/gallery/index.ts +1 -0
- package/gallery/screens.ts +3 -2
- package/package.json +8 -8
- package/sampleData/foundation-en.json +149 -0
- package/shell/layout.css +219 -0
- package/thumbnails/ConfirmDialog.svg +12 -0
- package/thumbnails/DetailDialog.svg +11 -0
- package/thumbnails/ErrorScreen.svg +11 -0
- package/thumbnails/FilterDialog.svg +16 -0
- package/thumbnails/ForbiddenScreen.svg +10 -0
- package/thumbnails/FormDialog.svg +12 -0
- package/thumbnails/LoginScreen.svg +12 -0
- package/thumbnails/LookupDialog.svg +13 -0
- package/thumbnails/NotFoundScreen.svg +9 -0
- package/thumbnails/QuickCreateDialog.svg +12 -0
- package/thumbnails/SideSheet.svg +13 -0
|
@@ -15,7 +15,7 @@ export interface ActionButtonProps {
|
|
|
15
15
|
onClick?: (event: { stopPropagation(): void }) => void;
|
|
16
16
|
|
|
17
17
|
/** The emphasis of the button. */
|
|
18
|
-
emphasis?: 'primary' | 'secondary' | 'quiet';
|
|
18
|
+
emphasis?: 'primary' | 'secondary' | 'quiet' | 'danger';
|
|
19
19
|
|
|
20
20
|
children: ReactNode;
|
|
21
21
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
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 { readOptionalString } from '../shell/elementProperties';
|
|
6
|
+
import { ActionButton } from './ActionButton';
|
|
7
|
+
import { readFoundationStrings } from './foundationStrings';
|
|
8
|
+
import { readPermissionRule } from './PermissionRule';
|
|
9
|
+
|
|
10
|
+
const emphases = ['primary', 'secondary', 'quiet', 'danger'] as const;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The `actionButton` component: one action of a screen or a dialog, drawn the way every action of the foundation is.
|
|
14
|
+
*
|
|
15
|
+
* `label` is what it does - a verb, never "OK" - and `emphasis` is `primary`, `secondary`, `quiet` or `danger` (the confirming button of a destructive
|
|
16
|
+
* dialog). It honours the permission rule of spec Part B 1.5 through `permission`: enabled, disabled (shown, refusing, and saying why) or not rendered.
|
|
17
|
+
* Pressing it runs the behaviors attached to the element, which is how a document makes it close a dialog, navigate or execute a command; with
|
|
18
|
+
* `targetScreen` and no behavior it goes to that screen, as the shell's links do.
|
|
19
|
+
*/
|
|
20
|
+
export function ActionButtonControl({ element, interactions }: RegisteredComponentProps) {
|
|
21
|
+
const strings = readFoundationStrings(element);
|
|
22
|
+
const requested = readOptionalString(element, 'emphasis');
|
|
23
|
+
const emphasis = (emphases as readonly string[]).includes(requested ?? '') ? (requested as typeof emphases[number]) : 'primary';
|
|
24
|
+
const target = readOptionalString(element, 'targetScreen');
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<span className='foundation foundation-action' data-scene-id={element.id}>
|
|
28
|
+
<ActionButton rule={readPermissionRule(element)} emphasis={emphasis} disabledReason={readOptionalString(element, 'disabledReason') ?? strings.disabledNoPermission}
|
|
29
|
+
onClick={event => {
|
|
30
|
+
if (interactions?.onClick) interactions.onClick(event);
|
|
31
|
+
else if (target && typeof window !== 'undefined') window.location.hash = `#/${target}`;
|
|
32
|
+
}}>
|
|
33
|
+
{readOptionalString(element, 'label') ?? ''}
|
|
34
|
+
</ActionButton>
|
|
35
|
+
</span>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
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 { useCallback, useContext, useEffect, useId, useRef } from 'react';
|
|
5
|
+
import { RegisteredComponentProps, SceneNavigationContextInternal } from '@cratis/scene.react';
|
|
6
|
+
import { readOptionalString, readStrings } from '../shell/elementProperties';
|
|
7
|
+
import { FoundationIcon } from './FoundationIcon';
|
|
8
|
+
import { formatText, readFoundationStrings } from './foundationStrings';
|
|
9
|
+
import { isRightToLeft, readLocale } from './formatting';
|
|
10
|
+
import { useCompactWidth } from './useCompactWidth';
|
|
11
|
+
|
|
12
|
+
/** Where a dialog is drawn: in the middle of the page, as a drawer from the inline end, or filling the page. */
|
|
13
|
+
export enum DialogPlacement {
|
|
14
|
+
Center = 'center',
|
|
15
|
+
Side = 'side',
|
|
16
|
+
Full = 'full',
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** What a dialog is about: an ordinary one, or one that cannot be undone and is drawn in the danger colors. */
|
|
20
|
+
export enum DialogTone {
|
|
21
|
+
Default = 'default',
|
|
22
|
+
Danger = 'danger',
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const focusable = 'input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), [href], [tabindex]:not([tabindex="-1"])';
|
|
26
|
+
|
|
27
|
+
/** Reads the placement property, falling back to the center for anything that is not one of the three. */
|
|
28
|
+
export function readDialogPlacement(element: RegisteredComponentProps['element']): DialogPlacement {
|
|
29
|
+
const value = readOptionalString(element, 'placement');
|
|
30
|
+
return value === DialogPlacement.Side || value === DialogPlacement.Full ? value : DialogPlacement.Center;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The placement a dialog is drawn with in a window of the given width class: a phone gets every dialog at full height and width (spec Part B 2.10, 1.5). */
|
|
34
|
+
export const placementFor = (requested: DialogPlacement, compact: boolean): DialogPlacement => (compact ? DialogPlacement.Full : requested);
|
|
35
|
+
|
|
36
|
+
/** The elements a dialog's focus can rest on, in document order, that are not hidden. */
|
|
37
|
+
const focusableIn = (host: HTMLElement): HTMLElement[] => Array.from(host.querySelectorAll<HTMLElement>(focusable)).filter(candidate => !candidate.hidden && candidate.closest('[hidden]') === null);
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The `dialogFrame` component: the head of a dialog template and everything that makes what the host draws around it behave as a dialog.
|
|
41
|
+
*
|
|
42
|
+
* It draws the heading (`title`, `subtitle`), an optional `message` and `consequences` list - what a confirmation says will happen - and a close button.
|
|
43
|
+
* The host draws the box a dialog template opens in (a `role="dialog" aria-modal="true"` element); this component takes that element on and gives it the
|
|
44
|
+
* parts of a dialog that must not differ from one template to the next:
|
|
45
|
+
*
|
|
46
|
+
* - it is named by the heading, takes focus when it opens (the first field, else the primary action, else the close button), keeps Tab inside it, and
|
|
47
|
+
* gives focus back to whatever opened it when it goes;
|
|
48
|
+
* - Escape and the close button run the behaviors attached to this element (a `CloseDialog` action), and close the dialog themselves when none is attached;
|
|
49
|
+
* - `placement` is `center` (default), `side` (a drawer from the inline end, which a right-to-left locale mirrors) or `full`; on a phone every dialog fills the page;
|
|
50
|
+
* - `tone` `danger` draws a dialog that cannot be undone in the danger colors.
|
|
51
|
+
*
|
|
52
|
+
* Every text it says comes from the strings table and the locale of the element, so an application localizes it like any foundation component.
|
|
53
|
+
*/
|
|
54
|
+
export function DialogFrame({ element, interactions }: RegisteredComponentProps) {
|
|
55
|
+
const frame = useRef<HTMLElement>(null);
|
|
56
|
+
const heading = useId();
|
|
57
|
+
const navigation = useContext(SceneNavigationContextInternal);
|
|
58
|
+
const compact = useCompactWidth();
|
|
59
|
+
const strings = readFoundationStrings(element);
|
|
60
|
+
const locale = readLocale(element);
|
|
61
|
+
const title = readOptionalString(element, 'title') ?? '';
|
|
62
|
+
const subtitle = readOptionalString(element, 'subtitle');
|
|
63
|
+
const message = readOptionalString(element, 'message');
|
|
64
|
+
const consequences = readStrings(element, 'consequences');
|
|
65
|
+
const placement = placementFor(readDialogPlacement(element), compact);
|
|
66
|
+
const tone = element.properties.tone === DialogTone.Danger ? DialogTone.Danger : DialogTone.Default;
|
|
67
|
+
|
|
68
|
+
const onClick = interactions?.onClick;
|
|
69
|
+
const close = useCallback(() => {
|
|
70
|
+
if (onClick) onClick({ stopPropagation: () => undefined });
|
|
71
|
+
else navigation?.closeDialog();
|
|
72
|
+
}, [onClick, navigation]);
|
|
73
|
+
const closeRef = useRef(close);
|
|
74
|
+
closeRef.current = close;
|
|
75
|
+
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
const host = frame.current?.closest<HTMLElement>('[role="dialog"]');
|
|
78
|
+
if (!host) return;
|
|
79
|
+
|
|
80
|
+
const opener = document.activeElement instanceof HTMLElement ? document.activeElement : undefined;
|
|
81
|
+
const before = { className: host.className, labelledBy: host.getAttribute('aria-labelledby'), placement: host.getAttribute('data-placement'), tone: host.getAttribute('data-tone') };
|
|
82
|
+
host.classList.add('foundation', 'foundation-dialog-host');
|
|
83
|
+
host.setAttribute('aria-labelledby', heading);
|
|
84
|
+
host.setAttribute('data-placement', placement);
|
|
85
|
+
host.setAttribute('data-tone', tone);
|
|
86
|
+
host.dir = isRightToLeft(locale) ? 'rtl' : host.dir;
|
|
87
|
+
|
|
88
|
+
const closeButton = frame.current?.querySelector<HTMLElement>('.foundation-dialog-close');
|
|
89
|
+
const targets = focusableIn(host);
|
|
90
|
+
(targets.find(target => /^(INPUT|SELECT|TEXTAREA)$/.test(target.tagName)) ?? targets.find(target => target !== closeButton) ?? closeButton)?.focus();
|
|
91
|
+
|
|
92
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
93
|
+
if (event.key === 'Escape') {
|
|
94
|
+
event.preventDefault();
|
|
95
|
+
closeRef.current();
|
|
96
|
+
} else if (event.key === 'Tab') {
|
|
97
|
+
const inside = focusableIn(host);
|
|
98
|
+
if (inside.length === 0) { event.preventDefault(); return; }
|
|
99
|
+
const first = inside[0];
|
|
100
|
+
const last = inside[inside.length - 1];
|
|
101
|
+
if (event.shiftKey && (document.activeElement === first || !host.contains(document.activeElement))) { event.preventDefault(); last.focus(); }
|
|
102
|
+
else if (!event.shiftKey && (document.activeElement === last || !host.contains(document.activeElement))) { event.preventDefault(); first.focus(); }
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
host.addEventListener('keydown', onKeyDown);
|
|
107
|
+
return () => {
|
|
108
|
+
host.removeEventListener('keydown', onKeyDown);
|
|
109
|
+
host.className = before.className;
|
|
110
|
+
for (const [name, value] of [['aria-labelledby', before.labelledBy], ['data-placement', before.placement], ['data-tone', before.tone]] as const) {
|
|
111
|
+
if (value === null) host.removeAttribute(name);
|
|
112
|
+
else host.setAttribute(name, value);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (opener?.isConnected) opener.focus();
|
|
116
|
+
};
|
|
117
|
+
}, [heading, placement, tone, locale]);
|
|
118
|
+
|
|
119
|
+
return (
|
|
120
|
+
<header ref={frame} className='foundation foundation-dialog-frame' data-scene-id={element.id} data-placement={placement} data-tone={tone} lang={locale} dir={isRightToLeft(locale) ? 'rtl' : undefined}>
|
|
121
|
+
<div className='foundation-dialog-heading'>
|
|
122
|
+
<div className='foundation-dialog-titles'>
|
|
123
|
+
<h2 id={heading} className='foundation-dialog-title'>{title}</h2>
|
|
124
|
+
{subtitle && <p className='foundation-dialog-subtitle'>{subtitle}</p>}
|
|
125
|
+
</div>
|
|
126
|
+
<button type='button' className='foundation-dialog-close' aria-label={formatText(strings.dialogClose, { title })} onClick={close}>
|
|
127
|
+
<FoundationIcon name='close' />
|
|
128
|
+
</button>
|
|
129
|
+
</div>
|
|
130
|
+
{message && <p className='foundation-dialog-message'>{message}</p>}
|
|
131
|
+
{consequences.length > 0 && (
|
|
132
|
+
<ul className='foundation-dialog-consequences' aria-label={strings.dialogConsequences}>
|
|
133
|
+
{consequences.map(consequence => <li key={consequence}>{consequence}</li>)}
|
|
134
|
+
</ul>
|
|
135
|
+
)}
|
|
136
|
+
</header>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
@@ -73,6 +73,9 @@ function FieldInput({ field, value, error, readOnly, locale, required, choose, s
|
|
|
73
73
|
case FormFieldType.Currency:
|
|
74
74
|
input = <input {...common} type='number' inputMode='decimal' value={text} placeholder={field.placeholder} onChange={event => set(event.target.value === '' ? '' : Number(event.target.value))} />;
|
|
75
75
|
break;
|
|
76
|
+
case FormFieldType.Password:
|
|
77
|
+
input = <input {...common} type='password' autoComplete='current-password' value={text} placeholder={field.placeholder} onChange={event => set(event.target.value)} />;
|
|
78
|
+
break;
|
|
76
79
|
case FormFieldType.Date:
|
|
77
80
|
input = <input {...common} type='date' lang={locale} value={text} onChange={event => set(event.target.value)} />;
|
|
78
81
|
break;
|
|
@@ -97,9 +100,9 @@ function FieldInput({ field, value, error, readOnly, locale, required, choose, s
|
|
|
97
100
|
* unsaved-changes guard.
|
|
98
101
|
*
|
|
99
102
|
* `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 `
|
|
103
|
+
* `toggle`, `file`, `richText`, `address` or `password`. 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
104
|
* `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
|
|
105
|
+
* *and* the word. `saveAndNew` false leaves out "Save and new" (a dialog that creates one record). `mode` is `create`, `edit`, `readOnly` or `settings` - settings has one Save for each section. The edited values are the `value` output; Save
|
|
103
106
|
* 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
107
|
* "Discard changes". The action bar sticks to the bottom as the form scrolls.
|
|
105
108
|
*/
|
|
@@ -185,7 +188,7 @@ export function FormScreenBody({ element, interactions, bindingOutputs }: Regist
|
|
|
185
188
|
<span className='foundation-wizard-spacer' />
|
|
186
189
|
{dirty && <ActionButton emphasis='quiet' disabledReason={strings.disabledNoPermission} onClick={() => { setValues(saved); setTouched(new Set()); setSubmitted(false); }}>{strings.formDiscard}</ActionButton>}
|
|
187
190
|
<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>}
|
|
191
|
+
{mode === FormMode.Create && element.properties.saveAndNew !== false && <ActionButton rule={canSave} emphasis='secondary' disabledReason={strings.disabledNoPermission} onClick={() => { submit(); }}>{strings.formSaveAndNew}</ActionButton>}
|
|
189
192
|
<ActionButton rule={canSave} disabledReason={strings.disabledNoPermission} onClick={() => submit()}>{strings.formSave}</ActionButton>
|
|
190
193
|
</div>
|
|
191
194
|
)}
|
|
@@ -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
|
+
import { RegisteredComponentProps } from '@cratis/scene.react';
|
|
5
|
+
import { readOptionalString } from '../shell/elementProperties';
|
|
6
|
+
import { FoundationIcon } from './FoundationIcon';
|
|
7
|
+
import { readLocale } from './formatting';
|
|
8
|
+
import { StatusTone, readStatusTone, statusToneIcons } from './StatusChip';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The `notice` component: one message about the region it sits in - a sign-in error, a warning above a form, a note under a field.
|
|
12
|
+
*
|
|
13
|
+
* It is a live region: a danger or warning notice is an `alert` and any other a `status`, and the region is on the page even while empty, so the
|
|
14
|
+
* message is announced when it arrives rather than only when the page loads. With no `text` nothing is drawn but the empty region, which is what a
|
|
15
|
+
* sign-in screen's error area is until a sign-in fails. `tone` picks the icon and the status colors; the message is always words, never color alone.
|
|
16
|
+
*/
|
|
17
|
+
export function Notice({ element }: RegisteredComponentProps) {
|
|
18
|
+
const tone = readStatusTone(element.properties.tone ?? StatusTone.Danger);
|
|
19
|
+
const text = readOptionalString(element, 'text') ?? '';
|
|
20
|
+
const title = readOptionalString(element, 'title');
|
|
21
|
+
const urgent = tone === StatusTone.Danger || tone === StatusTone.Warning;
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<div className='foundation foundation-notice' data-scene-id={element.id} data-tone={tone} data-empty={text === '' || undefined} role={urgent ? 'alert' : 'status'} lang={readLocale(element)}>
|
|
25
|
+
{text !== '' && (
|
|
26
|
+
<>
|
|
27
|
+
<FoundationIcon name={statusToneIcons[tone]} />
|
|
28
|
+
<p>{title && <strong>{title} </strong>}{text}</p>
|
|
29
|
+
</>
|
|
30
|
+
)}
|
|
31
|
+
</div>
|
|
32
|
+
);
|
|
33
|
+
}
|
package/foundation/formModel.ts
CHANGED
|
@@ -28,6 +28,9 @@ export const foundationComponentMetadata: Record<string, ComponentMetadata> = {
|
|
|
28
28
|
[ComponentName.DataList]: m('Data list', ComponentCategory.DataDisplay, 'table', ['table', 'grid', 'records', 'cards', 'collection', 'list']),
|
|
29
29
|
[ComponentName.TabSet]: m('Tab set', ComponentCategory.Navigation, 'panel-top', ['tabs', 'sections', 'panels']),
|
|
30
30
|
[ComponentName.SubNavigation]: m('Sub-navigation', ComponentCategory.Navigation, 'panel-left', ['module', 'sections', 'rail', 'tabs']),
|
|
31
|
+
[ComponentName.DialogFrame]: m('Dialog frame', ComponentCategory.Feedback, 'panel-top', ['dialog', 'modal', 'sheet', 'drawer', 'confirm', 'header']),
|
|
32
|
+
[ComponentName.ActionButton]: m('Action button', ComponentCategory.Actions, 'mouse-pointer-click', ['button', 'action', 'submit', 'cancel', 'confirm']),
|
|
33
|
+
[ComponentName.Notice]: m('Notice', ComponentCategory.Feedback, 'circle-alert', ['message', 'error', 'alert', 'banner', 'warning']),
|
|
31
34
|
[ComponentName.BulkBar]: m('Bulk bar', ComponentCategory.Actions, 'check-check', ['selection', 'bulk', 'batch', 'actions']),
|
|
32
35
|
[ComponentName.GlobalSearch]: m('Search', ComponentCategory.Input, 'search', ['search', 'find', 'query']),
|
|
33
36
|
[ComponentName.NotificationsButton]: m('Notifications button', ComponentCategory.Actions, 'bell', ['bell', 'alerts', 'unread']),
|
|
@@ -434,6 +434,46 @@ export const foundationDescriptors: ComponentDescriptor[] = [
|
|
|
434
434
|
...standardStateProperties.filter(property => property.path !== 'label'),
|
|
435
435
|
],
|
|
436
436
|
},
|
|
437
|
+
{
|
|
438
|
+
component: key(ComponentName.DialogFrame),
|
|
439
|
+
displayName: 'Dialog frame',
|
|
440
|
+
description: 'The head of a dialog: its title, a message and the consequences it states, and a close button. It makes the dialog it opens in keep focus inside, restore it, close on Escape and be drawn centered, as a side sheet or full height.',
|
|
441
|
+
properties: [
|
|
442
|
+
text('title', 'Title', 'Content', undefined, { constraints: { required: true } }),
|
|
443
|
+
text('subtitle', 'Subtitle', 'Content'),
|
|
444
|
+
text('message', 'Message', 'Content', 'What the dialog asks or says.'),
|
|
445
|
+
{ path: 'consequences', label: 'Consequences', group: 'Content', valueType: PropertyValueType.Json, editorKind: PropertyEditorKind.StringList, default: [], description: 'What will happen if the person goes ahead, one line each.' },
|
|
446
|
+
choice('placement', 'Placement', 'Appearance', { center: 'Centered', side: 'Side sheet', full: 'Full height' }, 'center', 'Where the dialog is drawn; on a phone every dialog fills the page.'),
|
|
447
|
+
choice('tone', 'Tone', 'Appearance', { default: 'Default', danger: 'Destructive' }, 'default', 'A destructive dialog is drawn in the danger colors.'),
|
|
448
|
+
text('locale', 'Locale', 'Content'),
|
|
449
|
+
{ path: 'strings', label: 'Text overrides', group: 'Content', valueType: PropertyValueType.Object, editorKind: PropertyEditorKind.StringMap },
|
|
450
|
+
],
|
|
451
|
+
},
|
|
452
|
+
{
|
|
453
|
+
component: key(ComponentName.ActionButton),
|
|
454
|
+
displayName: 'Action button',
|
|
455
|
+
description: 'One action, drawn as every action of the foundation is, that is enabled, disabled with its reason, or hidden by the permission of the user.',
|
|
456
|
+
properties: [
|
|
457
|
+
text('label', 'Label', 'Content', 'What it does - a verb, never "OK".', { constraints: { required: true } }),
|
|
458
|
+
choice('emphasis', 'Emphasis', 'Appearance', { primary: 'Primary', secondary: 'Secondary', quiet: 'Quiet', danger: 'Destructive' }, 'primary'),
|
|
459
|
+
rule('permission', 'Permission', 'Whether the action works, refuses with a reason or is not shown.'),
|
|
460
|
+
text('disabledReason', 'Reason when disabled', 'Permissions'),
|
|
461
|
+
text('targetScreen', 'Go to screen', 'Behavior', 'The screen it goes to when no behavior is attached.'),
|
|
462
|
+
text('locale', 'Locale', 'Content'),
|
|
463
|
+
{ path: 'strings', label: 'Text overrides', group: 'Content', valueType: PropertyValueType.Object, editorKind: PropertyEditorKind.StringMap },
|
|
464
|
+
],
|
|
465
|
+
},
|
|
466
|
+
{
|
|
467
|
+
component: key(ComponentName.Notice),
|
|
468
|
+
displayName: 'Notice',
|
|
469
|
+
description: 'One message about the region it sits in, announced when it arrives. Empty until it has something to say.',
|
|
470
|
+
properties: [
|
|
471
|
+
text('text', 'Message', 'Data', 'Bind to the error or note to show; empty draws nothing.'),
|
|
472
|
+
text('title', 'Title', 'Content'),
|
|
473
|
+
choice('tone', 'Tone', 'Appearance', { danger: 'Error', warning: 'Warning', info: 'Information', success: 'Success', neutral: 'Neutral' }, 'danger'),
|
|
474
|
+
text('locale', 'Locale', 'Content'),
|
|
475
|
+
],
|
|
476
|
+
},
|
|
437
477
|
{
|
|
438
478
|
component: key(ComponentName.BulkBar),
|
|
439
479
|
displayName: 'Bulk bar',
|
|
@@ -521,6 +561,7 @@ export const foundationDescriptors: ComponentDescriptor[] = [
|
|
|
521
561
|
displayName: 'Form',
|
|
522
562
|
description: 'Titled sections of fields in a one-to-three column grid, with inline validation, a save bar, the unsaved-changes guard and four modes.',
|
|
523
563
|
properties: [
|
|
564
|
+
flag('saveAndNew', 'Offer Save and new', 'Behavior', 'Whether a create form offers Save and new next to Save; a dialog that creates one record leaves it out.', { default: true }),
|
|
524
565
|
{
|
|
525
566
|
...list('sections', 'Sections', 'Data', 'Section', [
|
|
526
567
|
text('id', 'Id', 'Data'),
|
|
@@ -131,6 +131,9 @@ export const foundationSamples: Record<string, FoundationSample> = {
|
|
|
131
131
|
properties: { items: [{ id: 'accounts', label: 'Accounts' }, { id: 'contacts', label: 'Contacts', badge: '12' }, { id: 'deals', label: 'Deals' }] },
|
|
132
132
|
data: ['Accounts', 'Contacts'], stateful: true,
|
|
133
133
|
},
|
|
134
|
+
[ComponentName.DialogFrame]: { properties: { title: 'Archive product', message: 'Bamboo Watch will stop appearing in the catalog.', consequences: ['Existing orders keep it', 'It can be restored from the archive'], tone: 'danger' }, data: [], stateful: false },
|
|
135
|
+
[ComponentName.ActionButton]: { properties: { label: 'Archive product', emphasis: 'danger' }, data: [], stateful: false },
|
|
136
|
+
[ComponentName.Notice]: { properties: { text: 'The email or password is not right.', tone: 'danger' }, data: [], stateful: false },
|
|
134
137
|
[ComponentName.BulkBar]: { properties: { selection: ['1', '2'], actions: ['Assign', 'Archive'] }, data: [], stateful: false },
|
|
135
138
|
[ComponentName.GlobalSearch]: { properties: { placeholder: 'Search accounts', label: 'Search accounts' }, data: [], stateful: false },
|
|
136
139
|
[ComponentName.NotificationsButton]: { properties: { count: 3 }, data: [], stateful: false },
|
|
@@ -203,6 +203,8 @@ export const foundationStrings = {
|
|
|
203
203
|
matrixPermission: 'Permission',
|
|
204
204
|
matrixScroll: 'Permission matrix, scrolls sideways',
|
|
205
205
|
matrixGroupAll: 'Other',
|
|
206
|
+
dialogClose: 'Close {title}',
|
|
207
|
+
dialogConsequences: 'What will happen',
|
|
206
208
|
} as const;
|
|
207
209
|
|
|
208
210
|
/** The name of one of the foundation strings. */
|
package/foundation/index.ts
CHANGED
|
@@ -43,6 +43,9 @@ export * from './DataList';
|
|
|
43
43
|
export * from './tabModel';
|
|
44
44
|
export * from './TabSet';
|
|
45
45
|
export * from './SubNavigation';
|
|
46
|
+
export * from './DialogFrame';
|
|
47
|
+
export * from './Notice';
|
|
48
|
+
export * from './ActionButtonControl';
|
|
46
49
|
export * from './BulkBar';
|
|
47
50
|
export * from './GlobalSearch';
|
|
48
51
|
export * from './NotificationsButton';
|
|
@@ -0,0 +1,114 @@
|
|
|
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 { Behavior, DialogTemplate, ExternalComponent, InteractionActionKind, InteractionTriggerKind } from '@cratis/scene.model';
|
|
5
|
+
import { panel } from '../../gallery/elements';
|
|
6
|
+
import { defaultBlueprintTemplateProvenance } from '../../templateProvenance';
|
|
7
|
+
import { component, foundationTemplateProvenance, fromQuery, thumbnailOf } from './templateParts';
|
|
8
|
+
|
|
9
|
+
/** What a Cancel or Close control does: close the dialog the screen opened it from, in Scene, in Stage and in the preview alike. */
|
|
10
|
+
export const closeDialogBehaviors: Behavior[] = [{ bindings: [{ trigger: { kind: InteractionTriggerKind.Click }, actions: [{ kind: InteractionActionKind.CloseDialog }] }] }];
|
|
11
|
+
|
|
12
|
+
/** An element that closes the dialog when it is clicked. */
|
|
13
|
+
export const closingDialog = <T extends ExternalComponent>(element: T): T => ({ ...element, behaviors: closeDialogBehaviors });
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The provenance, type and category every foundation dialog carries. A dialog built from the foundation and core components needs no component library; one that
|
|
17
|
+
* uses PrimeReact widgets (`needsPrimeReact`) declares the packages it requires.
|
|
18
|
+
*/
|
|
19
|
+
export const dialogMetadata = (name: string, needsPrimeReact = false) =>
|
|
20
|
+
({ ...(needsPrimeReact ? defaultBlueprintTemplateProvenance : foundationTemplateProvenance), type: 'Dialog', category: 'Foundation / Dialog', thumbnail: thumbnailOf(name) });
|
|
21
|
+
|
|
22
|
+
/** One action of the foundation, by the id it is known by. */
|
|
23
|
+
export const actionButton = (id: string, properties: Record<string, unknown>): ExternalComponent => component(id, 'actionButton', properties);
|
|
24
|
+
|
|
25
|
+
/** The footer of a dialog: its buttons, the one that confirms last, so it is the one the eye ends on. */
|
|
26
|
+
export const dialogActions = (id: string, buttons: ExternalComponent[]) => panel(`${id}-actions`, buttons);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The quick-create dialog: a short form - six fields at most - opened from a list's header or from a lookup, to make one record without leaving the page.
|
|
30
|
+
* Cancel closes it, Create validates and saves. `FormDialog` is the general form dialog this one is the focused form of: the same shape with the heading,
|
|
31
|
+
* focus handling and the form of the foundation, which is why it is offered as the alias a quick-create button chooses.
|
|
32
|
+
*/
|
|
33
|
+
export const quickCreateDialogTemplate: DialogTemplate = {
|
|
34
|
+
name: 'QuickCreateDialog',
|
|
35
|
+
slots: [{ name: 'header' }, { name: 'body' }, { name: 'actions' }],
|
|
36
|
+
content: {
|
|
37
|
+
header: [component('quick-create-frame', 'dialogFrame', { title: 'New contact', subtitle: 'Only what is needed to get started' })],
|
|
38
|
+
body: [closingDialog(component('quick-create-form', 'formScreenBody', {
|
|
39
|
+
mode: 'create', saveAndNew: false, strings: { formSave: 'Create' },
|
|
40
|
+
sections: fromQuery('QuickCreateSections'), values: fromQuery('QuickCreateValues', '0'), errors: fromQuery('FormErrors', '0'),
|
|
41
|
+
}))],
|
|
42
|
+
actions: [],
|
|
43
|
+
},
|
|
44
|
+
displayName: 'Quick create',
|
|
45
|
+
description: 'A short form of six fields at most with Cancel and Create, opened without leaving the page.',
|
|
46
|
+
metadata: dialogMetadata('QuickCreateDialog'),
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The lookup dialog: search, a table or list, and single or multiple selection, with Cancel and Select. Any lookup field opens it, so one dialog serves them all;
|
|
51
|
+
* `selectionMode` on the list is `single` (the default here) or `multiple`.
|
|
52
|
+
*/
|
|
53
|
+
export const lookupDialogTemplate: DialogTemplate = {
|
|
54
|
+
name: 'LookupDialog',
|
|
55
|
+
slots: [{ name: 'header' }, { name: 'body' }, { name: 'actions' }],
|
|
56
|
+
content: {
|
|
57
|
+
header: [component('lookup-frame', 'dialogFrame', { title: 'Select an account', subtitle: 'Search, then choose one' })],
|
|
58
|
+
body: [
|
|
59
|
+
component('lookup-search', 'globalSearch', { placeholder: 'Search accounts', label: 'Search accounts' }),
|
|
60
|
+
component('lookup-list', 'dataList', { label: 'Accounts', rows: fromQuery('LookupRows'), columns: fromQuery('LookupColumns'), titleField: 'name', selectionMode: 'single', pageSize: 6 }),
|
|
61
|
+
],
|
|
62
|
+
actions: [dialogActions('lookup', [
|
|
63
|
+
closingDialog(actionButton('lookup-cancel', { label: 'Cancel', emphasis: 'secondary' })),
|
|
64
|
+
closingDialog(actionButton('lookup-select', { label: 'Select', emphasis: 'primary' })),
|
|
65
|
+
])],
|
|
66
|
+
},
|
|
67
|
+
displayName: 'Lookup',
|
|
68
|
+
description: 'A search and a list to choose one or several records from, with Cancel and Select.',
|
|
69
|
+
metadata: dialogMetadata('LookupDialog'),
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The side sheet: a drawer from the inline end with a header, a scrollable body and footer actions. It hosts what a page hosts - a form, or a record's
|
|
74
|
+
* tabs and related records - so a record can be edited or read from beside the list it came from.
|
|
75
|
+
*/
|
|
76
|
+
export const sideSheetTemplate: DialogTemplate = {
|
|
77
|
+
name: 'SideSheet',
|
|
78
|
+
slots: [{ name: 'header' }, { name: 'body' }, { name: 'actions' }],
|
|
79
|
+
content: {
|
|
80
|
+
header: [component('sheet-frame', 'dialogFrame', { title: 'Account details', subtitle: 'Edit without leaving the list', placement: 'side' })],
|
|
81
|
+
body: [closingDialog(component('sheet-form', 'formScreenBody', { mode: 'edit', sections: fromQuery('RecordSections'), values: fromQuery('RecordValues', '0'), errors: fromQuery('FormErrors', '0') }))],
|
|
82
|
+
actions: [dialogActions('sheet', [
|
|
83
|
+
closingDialog(actionButton('sheet-close', { label: 'Close', emphasis: 'secondary' })),
|
|
84
|
+
actionButton('sheet-open', { label: 'Open full page', emphasis: 'primary', targetScreen: 'RecordPage' }),
|
|
85
|
+
])],
|
|
86
|
+
},
|
|
87
|
+
displayName: 'Side sheet',
|
|
88
|
+
description: 'A drawer from the side hosting a record or a form, with footer actions.',
|
|
89
|
+
metadata: dialogMetadata('SideSheet'),
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The filter dialog: the advanced filter builder in a dialog. On a phone it fills the page, which makes it the phone's alternative to the filter panel
|
|
94
|
+
* a list shows beside its table.
|
|
95
|
+
*/
|
|
96
|
+
export const filterDialogTemplate: DialogTemplate = {
|
|
97
|
+
name: 'FilterDialog',
|
|
98
|
+
slots: [{ name: 'header' }, { name: 'body' }, { name: 'actions' }],
|
|
99
|
+
content: {
|
|
100
|
+
header: [component('filter-frame', 'dialogFrame', { title: 'Filters', subtitle: 'Show only the deals that match' })],
|
|
101
|
+
body: [component('filter-builder', 'filterBuilder', { fields: fromQuery('DealFilterFields'), filter: fromQuery('DealFilter', '0') })],
|
|
102
|
+
actions: [dialogActions('filter', [
|
|
103
|
+
actionButton('filter-reset', { label: 'Reset', emphasis: 'secondary' }),
|
|
104
|
+
closingDialog(actionButton('filter-cancel', { label: 'Cancel', emphasis: 'secondary' })),
|
|
105
|
+
closingDialog(actionButton('filter-apply', { label: 'Apply filters', emphasis: 'primary' })),
|
|
106
|
+
])],
|
|
107
|
+
},
|
|
108
|
+
displayName: 'Filters',
|
|
109
|
+
description: 'The advanced filter builder in a dialog, the phone alternative to the filter panel.',
|
|
110
|
+
metadata: dialogMetadata('FilterDialog'),
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/** The dialogs the foundation adds to the three the gallery already had. */
|
|
114
|
+
export const foundationDialogTemplates: DialogTemplate[] = [quickCreateDialogTemplate, lookupDialogTemplate, sideSheetTemplate, filterDialogTemplate];
|
|
@@ -11,6 +11,8 @@ import { wizardScreenTemplate } from './wizardScreenTemplate';
|
|
|
11
11
|
import { workspaceScreenTemplate } from './workspaceScreenTemplate';
|
|
12
12
|
|
|
13
13
|
export * from './templateParts';
|
|
14
|
+
export * from './dialogTemplates';
|
|
15
|
+
export * from './stateScreenTemplates';
|
|
14
16
|
export * from './boardScreenTemplate';
|
|
15
17
|
export * from './dashboardScreenTemplate';
|
|
16
18
|
export * from './formScreenTemplate';
|
|
@@ -0,0 +1,107 @@
|
|
|
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 { ScreenTemplate, TemplateMetadata } from '@cratis/scene.model';
|
|
5
|
+
import { externalComponent, panel } from '../../gallery/elements';
|
|
6
|
+
import { actionButton } from './dialogTemplates';
|
|
7
|
+
import { column, slotLeaf } from '../../layouts/flowBuilders';
|
|
8
|
+
import { component, foundationTemplateProvenance, fromQuery, thumbnailOf } from './templateParts';
|
|
9
|
+
|
|
10
|
+
const metadataOf = (name: string, type: 'Auth' | 'State'): TemplateMetadata => ({ ...foundationTemplateProvenance, type, category: `Foundation / ${type}`, thumbnail: thumbnailOf(name) });
|
|
11
|
+
|
|
12
|
+
/** The way back every state screen offers: home, which is always somewhere the person is allowed to be. */
|
|
13
|
+
const backToDashboard = (id: string) => actionButton(id, { label: 'Back to the dashboard', emphasis: 'secondary', targetScreen: 'DashboardScreen' });
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The sign-in screen: the logo, the sign-in form, a slot for the identity providers the application offers (`providers` - buttons, each starting its
|
|
17
|
+
* provider's flow, which the application fills), an error area that is a live region and says why a sign-in failed, and a link to help. The form is
|
|
18
|
+
* made of the foundation's form components, so it validates and announces like every other form.
|
|
19
|
+
*/
|
|
20
|
+
export const loginScreenTemplate: ScreenTemplate = {
|
|
21
|
+
name: 'LoginScreen',
|
|
22
|
+
fitsSlot: 'content',
|
|
23
|
+
slots: [{ name: 'logo' }, { name: 'error' }, { name: 'form' }, { name: 'providers' }, { name: 'help' }],
|
|
24
|
+
arrangement: { root: column([slotLeaf('logo'), slotLeaf('error'), slotLeaf('form'), slotLeaf('providers'), slotLeaf('help')], 16) },
|
|
25
|
+
content: {
|
|
26
|
+
logo: [externalComponent('login-logo', 'logo', { label: 'Contoso', initials: 'C' })],
|
|
27
|
+
error: [component('login-error', 'notice', { text: fromQuery('SignInError', '0.message'), tone: 'danger' })],
|
|
28
|
+
form: [component('login-form', 'formScreenBody', {
|
|
29
|
+
mode: 'create', saveAndNew: false, strings: { formSave: 'Sign in', formCancel: 'Clear' },
|
|
30
|
+
sections: fromQuery('SignInSections'), values: fromQuery('SignInValues', '0'), errors: fromQuery('FormErrors', '0'),
|
|
31
|
+
})],
|
|
32
|
+
providers: [panel('login-providers', [actionButton('login-microsoft', { label: 'Continue with Microsoft', emphasis: 'secondary' }), actionButton('login-github', { label: 'Continue with GitHub', emphasis: 'secondary' })])],
|
|
33
|
+
help: [actionButton('login-help', { label: 'Need help signing in?', emphasis: 'quiet', targetScreen: 'ForbiddenScreen' })],
|
|
34
|
+
},
|
|
35
|
+
displayName: 'Sign in',
|
|
36
|
+
description: 'A sign-in form with a slot for identity providers, an error area and a help link.',
|
|
37
|
+
metadata: metadataOf('LoginScreen', 'Auth'),
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** The wrong address: what happened in words, a search, and the way home. */
|
|
41
|
+
export const notFoundScreenTemplate: ScreenTemplate = {
|
|
42
|
+
name: 'NotFoundScreen',
|
|
43
|
+
fitsSlot: 'content',
|
|
44
|
+
slots: [{ name: 'message' }, { name: 'search' }, { name: 'actions' }],
|
|
45
|
+
arrangement: { root: column([slotLeaf('message'), slotLeaf('search'), slotLeaf('actions')], 16) },
|
|
46
|
+
content: {
|
|
47
|
+
message: [component('not-found-message', 'emptyState', { icon: 'file', headline: 'That page is not here', hint: 'The link may be old, or the thing it pointed at may have been removed.' })],
|
|
48
|
+
search: [component('not-found-search', 'globalSearch', { label: 'Search the application', placeholder: 'Search instead' })],
|
|
49
|
+
actions: [backToDashboard('not-found-home')],
|
|
50
|
+
},
|
|
51
|
+
displayName: 'Not found',
|
|
52
|
+
description: 'A wrong address explained, with a search and a link home.',
|
|
53
|
+
metadata: metadataOf('NotFoundScreen', 'State'),
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The refusal: signed in, and still not allowed. It says so and offers to ask for access, and it shows nothing of what was refused - the template binds
|
|
58
|
+
* no data at all, so there is nothing to leak.
|
|
59
|
+
*/
|
|
60
|
+
export const forbiddenScreenTemplate: ScreenTemplate = {
|
|
61
|
+
name: 'ForbiddenScreen',
|
|
62
|
+
fitsSlot: 'content',
|
|
63
|
+
slots: [{ name: 'message' }, { name: 'actions' }],
|
|
64
|
+
arrangement: { root: column([slotLeaf('message'), slotLeaf('actions')], 16) },
|
|
65
|
+
content: {
|
|
66
|
+
message: [component('forbidden-message', 'emptyState', {
|
|
67
|
+
icon: 'lock', headline: 'You do not have access to this', hint: 'Your account is signed in, but it is not allowed here. An administrator can change that.', actionLabel: 'Request access',
|
|
68
|
+
})],
|
|
69
|
+
actions: [backToDashboard('forbidden-home')],
|
|
70
|
+
},
|
|
71
|
+
displayName: 'Forbidden',
|
|
72
|
+
description: 'A refusal that offers to request access and shows none of what was refused.',
|
|
73
|
+
metadata: metadataOf('ForbiddenScreen', 'State'),
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/** The failure on our side: a plain message, a retry, and the support reference id a person can quote. */
|
|
77
|
+
export const errorScreenTemplate: ScreenTemplate = {
|
|
78
|
+
name: 'ErrorScreen',
|
|
79
|
+
fitsSlot: 'content',
|
|
80
|
+
slots: [{ name: 'message' }, { name: 'reference' }, { name: 'actions' }],
|
|
81
|
+
arrangement: { root: column([slotLeaf('message'), slotLeaf('reference'), slotLeaf('actions')], 16) },
|
|
82
|
+
content: {
|
|
83
|
+
message: [component('error-message', 'emptyState', {
|
|
84
|
+
icon: 'alert', headline: 'Something went wrong on our side', hint: 'The team has been told. Nothing you were working on was lost.', actionLabel: 'Try again',
|
|
85
|
+
})],
|
|
86
|
+
reference: [panel('error-reference', [
|
|
87
|
+
externalComponent('error-reference-label', 'text', { text: 'Support reference' }),
|
|
88
|
+
externalComponent('error-reference-id', 'text', { text: fromQuery('SupportReference', '0.id') }),
|
|
89
|
+
])],
|
|
90
|
+
actions: [backToDashboard('error-home')],
|
|
91
|
+
},
|
|
92
|
+
displayName: 'Error',
|
|
93
|
+
description: 'A generic failure with a retry and the support reference id to quote.',
|
|
94
|
+
metadata: metadataOf('ErrorScreen', 'State'),
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/** The four auth and state screens of Part B 2.11. The legacy `Login`, `NotFound`, `AccessDenied` and `Error` remain as they were. */
|
|
98
|
+
export const stateScreenTemplates: ScreenTemplate[] = [loginScreenTemplate, notFoundScreenTemplate, forbiddenScreenTemplate, errorScreenTemplate];
|
|
99
|
+
|
|
100
|
+
/** The earlier template each spec-named one stands beside: the old name keeps working, and is the alias of the new. */
|
|
101
|
+
export const foundationTemplateAliases: Record<string, string> = {
|
|
102
|
+
LoginScreen: 'Login',
|
|
103
|
+
NotFoundScreen: 'NotFound',
|
|
104
|
+
ForbiddenScreen: 'AccessDenied',
|
|
105
|
+
ErrorScreen: 'Error',
|
|
106
|
+
QuickCreateDialog: 'FormDialog',
|
|
107
|
+
};
|