@cratis/scene.blueprint.default 4.31.0 → 4.33.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 +24 -0
- package/defaultBlueprint.ts +2 -1
- package/defaultBlueprintComponentMetadata.ts +2 -0
- package/defaultBlueprintComponents.ts +9 -0
- package/dist/cjs/ComponentName.js +8 -0
- package/dist/cjs/ComponentName.js.map +1 -1
- package/dist/cjs/defaultBlueprint.js +9 -5
- package/dist/cjs/defaultBlueprint.js.map +1 -1
- package/dist/cjs/defaultBlueprintComponentMetadata.js +8 -0
- package/dist/cjs/defaultBlueprintComponentMetadata.js.map +1 -1
- package/dist/cjs/defaultBlueprintComponents.js +21 -1
- package/dist/cjs/defaultBlueprintComponents.js.map +1 -1
- package/dist/cjs/foundation/ActionButton.js +33 -0
- package/dist/cjs/foundation/ActionButton.js.map +1 -0
- package/dist/cjs/foundation/ChartWrapper.js +520 -0
- package/dist/cjs/foundation/ChartWrapper.js.map +1 -0
- package/dist/cjs/foundation/ColumnChooser.js +156 -0
- package/dist/cjs/foundation/ColumnChooser.js.map +1 -0
- package/dist/cjs/foundation/ComponentState.js +42 -0
- package/dist/cjs/foundation/ComponentState.js.map +1 -0
- package/dist/cjs/foundation/EmptyState.js +31 -0
- package/dist/cjs/foundation/EmptyState.js.map +1 -0
- package/dist/cjs/foundation/FilterBuilder.js +307 -0
- package/dist/cjs/foundation/FilterBuilder.js.map +1 -0
- package/dist/cjs/foundation/FoundationIcon.js +58 -0
- package/dist/cjs/foundation/FoundationIcon.js.map +1 -0
- package/dist/cjs/foundation/FoundationStates.js +62 -0
- package/dist/cjs/foundation/FoundationStates.js.map +1 -0
- package/dist/cjs/foundation/PermissionRule.js +22 -0
- package/dist/cjs/foundation/PermissionRule.js.map +1 -0
- package/dist/cjs/foundation/SavedViewsMenu.js +199 -0
- package/dist/cjs/foundation/SavedViewsMenu.js.map +1 -0
- package/dist/cjs/foundation/StatCard.js +61 -0
- package/dist/cjs/foundation/StatCard.js.map +1 -0
- package/dist/cjs/foundation/StateFrame.js +200 -0
- package/dist/cjs/foundation/StateFrame.js.map +1 -0
- package/dist/cjs/foundation/StatusChip.js +64 -0
- package/dist/cjs/foundation/StatusChip.js.map +1 -0
- package/dist/cjs/foundation/TrendChip.js +93 -0
- package/dist/cjs/foundation/TrendChip.js.map +1 -0
- package/dist/cjs/foundation/chartGeometry.js +101 -0
- package/dist/cjs/foundation/chartGeometry.js.map +1 -0
- package/dist/cjs/foundation/columnModel.js +43 -0
- package/dist/cjs/foundation/columnModel.js.map +1 -0
- package/dist/cjs/foundation/filterModel.js +123 -0
- package/dist/cjs/foundation/filterModel.js.map +1 -0
- package/dist/cjs/foundation/formatting.js +85 -0
- package/dist/cjs/foundation/formatting.js.map +1 -0
- package/dist/cjs/foundation/foundationComponentMetadata.js +69 -0
- package/dist/cjs/foundation/foundationComponentMetadata.js.map +1 -0
- package/dist/cjs/foundation/foundationDescriptors.js +385 -0
- package/dist/cjs/foundation/foundationDescriptors.js.map +1 -0
- package/dist/cjs/foundation/foundationSamples.js +201 -0
- package/dist/cjs/foundation/foundationSamples.js.map +1 -0
- package/dist/cjs/foundation/foundationStrings.js +130 -0
- package/dist/cjs/foundation/foundationStrings.js.map +1 -0
- package/dist/cjs/foundation/useElementWidth.js +31 -0
- package/dist/cjs/foundation/useElementWidth.js.map +1 -0
- package/dist/cjs/foundation/viewModel.js +24 -0
- package/dist/cjs/foundation/viewModel.js.map +1 -0
- package/dist/cjs/index.js +90 -0
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/themes/dark.js +9 -1
- package/dist/cjs/themes/dark.js.map +1 -1
- package/dist/cjs/themes/light.js +10 -2
- package/dist/cjs/themes/light.js.map +1 -1
- package/dist/esm/ComponentName.d.ts +9 -1
- package/dist/esm/ComponentName.d.ts.map +1 -1
- package/dist/esm/ComponentName.js +8 -0
- package/dist/esm/ComponentName.js.map +1 -1
- package/dist/esm/defaultBlueprint.d.ts.map +1 -1
- package/dist/esm/defaultBlueprint.js +9 -5
- package/dist/esm/defaultBlueprint.js.map +1 -1
- package/dist/esm/defaultBlueprintComponentMetadata.d.ts.map +1 -1
- package/dist/esm/defaultBlueprintComponentMetadata.js +8 -0
- package/dist/esm/defaultBlueprintComponentMetadata.js.map +1 -1
- package/dist/esm/defaultBlueprintComponents.d.ts.map +1 -1
- package/dist/esm/defaultBlueprintComponents.js +21 -1
- package/dist/esm/defaultBlueprintComponents.js.map +1 -1
- package/dist/esm/foundation/ActionButton.d.ts +13 -0
- package/dist/esm/foundation/ActionButton.d.ts.map +1 -0
- package/dist/esm/foundation/ActionButton.js +31 -0
- package/dist/esm/foundation/ActionButton.js.map +1 -0
- package/dist/esm/foundation/ChartWrapper.d.ts +5 -0
- package/dist/esm/foundation/ChartWrapper.d.ts.map +1 -0
- package/dist/esm/foundation/ChartWrapper.js +517 -0
- package/dist/esm/foundation/ChartWrapper.js.map +1 -0
- package/dist/esm/foundation/ColumnChooser.d.ts +3 -0
- package/dist/esm/foundation/ColumnChooser.d.ts.map +1 -0
- package/dist/esm/foundation/ColumnChooser.js +154 -0
- package/dist/esm/foundation/ColumnChooser.js.map +1 -0
- package/dist/esm/foundation/ComponentState.d.ts +12 -0
- package/dist/esm/foundation/ComponentState.d.ts.map +1 -0
- package/dist/esm/foundation/ComponentState.js +38 -0
- package/dist/esm/foundation/ComponentState.js.map +1 -0
- package/dist/esm/foundation/EmptyState.d.ts +3 -0
- package/dist/esm/foundation/EmptyState.d.ts.map +1 -0
- package/dist/esm/foundation/EmptyState.js +29 -0
- package/dist/esm/foundation/EmptyState.js.map +1 -0
- package/dist/esm/foundation/FilterBuilder.d.ts +3 -0
- package/dist/esm/foundation/FilterBuilder.d.ts.map +1 -0
- package/dist/esm/foundation/FilterBuilder.js +305 -0
- package/dist/esm/foundation/FilterBuilder.js.map +1 -0
- package/dist/esm/foundation/Foundation.stories.d.ts +37 -0
- package/dist/esm/foundation/Foundation.stories.d.ts.map +1 -0
- package/dist/esm/foundation/Foundation.stories.js +34 -0
- package/dist/esm/foundation/Foundation.stories.js.map +1 -0
- package/dist/esm/foundation/FoundationIcon.d.ts +7 -0
- package/dist/esm/foundation/FoundationIcon.d.ts.map +1 -0
- package/dist/esm/foundation/FoundationIcon.js +55 -0
- package/dist/esm/foundation/FoundationIcon.js.map +1 -0
- package/dist/esm/foundation/FoundationStates.d.ts +8 -0
- package/dist/esm/foundation/FoundationStates.d.ts.map +1 -0
- package/dist/esm/foundation/FoundationStates.js +60 -0
- package/dist/esm/foundation/FoundationStates.js.map +1 -0
- package/dist/esm/foundation/PermissionRule.d.ts +8 -0
- package/dist/esm/foundation/PermissionRule.d.ts.map +1 -0
- package/dist/esm/foundation/PermissionRule.js +19 -0
- package/dist/esm/foundation/PermissionRule.js.map +1 -0
- package/dist/esm/foundation/SavedViewsMenu.d.ts +4 -0
- package/dist/esm/foundation/SavedViewsMenu.d.ts.map +1 -0
- package/dist/esm/foundation/SavedViewsMenu.js +196 -0
- package/dist/esm/foundation/SavedViewsMenu.js.map +1 -0
- package/dist/esm/foundation/StatCard.d.ts +3 -0
- package/dist/esm/foundation/StatCard.d.ts.map +1 -0
- package/dist/esm/foundation/StatCard.js +59 -0
- package/dist/esm/foundation/StatCard.js.map +1 -0
- package/dist/esm/foundation/StateFrame.d.ts +15 -0
- package/dist/esm/foundation/StateFrame.d.ts.map +1 -0
- package/dist/esm/foundation/StateFrame.js +197 -0
- package/dist/esm/foundation/StateFrame.js.map +1 -0
- package/dist/esm/foundation/StatusChip.d.ts +13 -0
- package/dist/esm/foundation/StatusChip.d.ts.map +1 -0
- package/dist/esm/foundation/StatusChip.js +59 -0
- package/dist/esm/foundation/StatusChip.js.map +1 -0
- package/dist/esm/foundation/TrendChip.d.ts +18 -0
- package/dist/esm/foundation/TrendChip.d.ts.map +1 -0
- package/dist/esm/foundation/TrendChip.js +88 -0
- package/dist/esm/foundation/TrendChip.js.map +1 -0
- package/dist/esm/foundation/chartGeometry.d.ts +34 -0
- package/dist/esm/foundation/chartGeometry.d.ts.map +1 -0
- package/dist/esm/foundation/chartGeometry.js +93 -0
- package/dist/esm/foundation/chartGeometry.js.map +1 -0
- package/dist/esm/foundation/columnModel.d.ts +11 -0
- package/dist/esm/foundation/columnModel.d.ts.map +1 -0
- package/dist/esm/foundation/columnModel.js +38 -0
- package/dist/esm/foundation/columnModel.js.map +1 -0
- package/dist/esm/foundation/filterModel.d.ts +33 -0
- package/dist/esm/foundation/filterModel.d.ts.map +1 -0
- package/dist/esm/foundation/filterModel.js +112 -0
- package/dist/esm/foundation/filterModel.js.map +1 -0
- package/dist/esm/foundation/formatting.d.ts +13 -0
- package/dist/esm/foundation/formatting.d.ts.map +1 -0
- package/dist/esm/foundation/formatting.js +78 -0
- package/dist/esm/foundation/formatting.js.map +1 -0
- package/dist/esm/foundation/foundationComponentMetadata.d.ts +3 -0
- package/dist/esm/foundation/foundationComponentMetadata.d.ts.map +1 -0
- package/dist/esm/foundation/foundationComponentMetadata.js +67 -0
- package/dist/esm/foundation/foundationComponentMetadata.js.map +1 -0
- package/dist/esm/foundation/foundationDescriptors.d.ts +6 -0
- package/dist/esm/foundation/foundationDescriptors.d.ts.map +1 -0
- package/dist/esm/foundation/foundationDescriptors.js +380 -0
- package/dist/esm/foundation/foundationDescriptors.js.map +1 -0
- package/dist/esm/foundation/foundationSamples.d.ts +10 -0
- package/dist/esm/foundation/foundationSamples.d.ts.map +1 -0
- package/dist/esm/foundation/foundationSamples.js +198 -0
- package/dist/esm/foundation/foundationSamples.js.map +1 -0
- package/dist/esm/foundation/foundationStrings.d.ts +103 -0
- package/dist/esm/foundation/foundationStrings.d.ts.map +1 -0
- package/dist/esm/foundation/foundationStrings.js +126 -0
- package/dist/esm/foundation/foundationStrings.js.map +1 -0
- package/dist/esm/foundation/index.d.ts +25 -0
- package/dist/esm/foundation/index.d.ts.map +1 -0
- package/dist/esm/foundation/index.js +25 -0
- package/dist/esm/foundation/index.js.map +1 -0
- package/dist/esm/foundation/useElementWidth.d.ts +3 -0
- package/dist/esm/foundation/useElementWidth.d.ts.map +1 -0
- package/dist/esm/foundation/useElementWidth.js +29 -0
- package/dist/esm/foundation/useElementWidth.js.map +1 -0
- package/dist/esm/foundation/viewModel.d.ts +7 -0
- package/dist/esm/foundation/viewModel.d.ts.map +1 -0
- package/dist/esm/foundation/viewModel.js +22 -0
- package/dist/esm/foundation/viewModel.js.map +1 -0
- package/dist/esm/gallery/widgets.js +1 -1
- package/dist/esm/index.d.ts +1 -0
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +24 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/themes/dark.d.ts.map +1 -1
- package/dist/esm/themes/dark.js +9 -1
- package/dist/esm/themes/dark.js.map +1 -1
- package/dist/esm/themes/light.d.ts.map +1 -1
- package/dist/esm/themes/light.js +10 -2
- package/dist/esm/themes/light.js.map +1 -1
- package/dist/esm/tsconfig.tsbuildinfo +1 -1
- package/foundation/ActionButton.tsx +39 -0
- package/foundation/ChartWrapper.tsx +252 -0
- package/foundation/ColumnChooser.tsx +73 -0
- package/foundation/ComponentState.ts +53 -0
- package/foundation/EmptyState.tsx +22 -0
- package/foundation/FilterBuilder.tsx +144 -0
- package/foundation/Foundation.stories.tsx +46 -0
- package/foundation/FoundationIcon.tsx +49 -0
- package/foundation/FoundationStates.tsx +46 -0
- package/foundation/PermissionRule.ts +26 -0
- package/foundation/SavedViewsMenu.tsx +94 -0
- package/foundation/StatCard.tsx +44 -0
- package/foundation/StateFrame.tsx +126 -0
- package/foundation/StatusChip.tsx +51 -0
- package/foundation/TrendChip.tsx +78 -0
- package/foundation/chartGeometry.ts +108 -0
- package/foundation/columnModel.ts +39 -0
- package/foundation/filterModel.ts +111 -0
- package/foundation/formatting.ts +69 -0
- package/foundation/foundationComponentMetadata.ts +21 -0
- package/foundation/foundationDescriptors.ts +204 -0
- package/foundation/foundationSamples.ts +63 -0
- package/foundation/foundationStrings.ts +137 -0
- package/foundation/index.ts +27 -0
- package/foundation/useElementWidth.ts +31 -0
- package/foundation/viewModel.ts +21 -0
- package/index.ts +1 -0
- package/package.json +9 -8
- package/shell/layout.css +526 -0
- package/themes/dark.ts +8 -0
- package/themes/light.ts +9 -1
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Copyright (c) Cratis. All rights reserved.
|
|
2
|
+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
3
|
+
|
|
4
|
+
import { ReactNode } from 'react';
|
|
5
|
+
import { PermissionRule } from './PermissionRule';
|
|
6
|
+
|
|
7
|
+
export interface ActionButtonProps {
|
|
8
|
+
/** The permission rule that decides whether the control works, refuses or is not rendered. */
|
|
9
|
+
rule?: PermissionRule;
|
|
10
|
+
|
|
11
|
+
/** Why the control is disabled, announced and shown as its tooltip. */
|
|
12
|
+
disabledReason: string;
|
|
13
|
+
|
|
14
|
+
/** What the control does when pressed. */
|
|
15
|
+
onClick?: (event: { stopPropagation(): void }) => void;
|
|
16
|
+
|
|
17
|
+
/** The emphasis of the button. */
|
|
18
|
+
emphasis?: 'primary' | 'secondary' | 'quiet';
|
|
19
|
+
|
|
20
|
+
children: ReactNode;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The one button every foundation component starts an action with, so the permission rule of spec Part B 1.5 is honoured in one place:
|
|
25
|
+
* hidden renders nothing; disabled stays focusable-by-description (`aria-disabled`) and carries the reason, rather than a bare
|
|
26
|
+
* greyed control nobody can explain.
|
|
27
|
+
*/
|
|
28
|
+
export function ActionButton({ rule = PermissionRule.Enabled, disabledReason, onClick, emphasis = 'primary', children }: ActionButtonProps) {
|
|
29
|
+
if (rule === PermissionRule.Hidden) return undefined;
|
|
30
|
+
|
|
31
|
+
const disabled = rule === PermissionRule.Disabled;
|
|
32
|
+
return (
|
|
33
|
+
<button type='button' className='foundation-button' data-emphasis={emphasis} aria-disabled={disabled || undefined}
|
|
34
|
+
title={disabled ? disabledReason : undefined} onClick={disabled ? undefined : onClick}>
|
|
35
|
+
{children}
|
|
36
|
+
{disabled && <span className='foundation-visually-hidden'>{`. ${disabledReason}`}</span>}
|
|
37
|
+
</button>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
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 { useId, useRef } from 'react';
|
|
5
|
+
import { RegisteredComponentProps } from '@cratis/scene.react';
|
|
6
|
+
import { readOptionalString, readStrings } from '../shell/elementProperties';
|
|
7
|
+
import { ChartKind, ChartSeries, dataMaximum, donutSlices, funnelStages, niceScale, readSeries, ringPath } from './chartGeometry';
|
|
8
|
+
import { ComponentState, readComponentState } from './ComponentState';
|
|
9
|
+
import { formatNumber, NumberFormat, readLocale, readNumberFormat } from './formatting';
|
|
10
|
+
import { readFoundationStrings } from './foundationStrings';
|
|
11
|
+
import { FoundationFrame } from './StateFrame';
|
|
12
|
+
import { useElementWidth } from './useElementWidth';
|
|
13
|
+
|
|
14
|
+
/** Reads the chart kind, defaulting to a line chart. */
|
|
15
|
+
export function readChartKind(value: unknown): ChartKind {
|
|
16
|
+
return (Object.values(ChartKind) as string[]).includes(value as string) ? (value as ChartKind) : ChartKind.Line;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The theme tokens series are drawn in, in order; a series is never told apart by color alone - see `markers` and `patterns`. */
|
|
20
|
+
const seriesColors = ['var(--foundation-primary)', 'var(--foundation-success)', 'var(--foundation-warning)', 'var(--foundation-danger)', 'var(--foundation-info)', 'var(--foundation-muted)'];
|
|
21
|
+
const markers = ['circle', 'square', 'triangle', 'diamond', 'cross', 'ring'] as const;
|
|
22
|
+
const dashes = ['', '6 3', '2 3', '8 3 2 3', '', '4 4'];
|
|
23
|
+
const margin = { top: 12, right: 12, bottom: 28, left: 48 };
|
|
24
|
+
|
|
25
|
+
type Marker = (typeof markers)[number];
|
|
26
|
+
|
|
27
|
+
function MarkerShape({ kind, x, y, color }: { kind: Marker; x: number; y: number; color: string }) {
|
|
28
|
+
const r = 4;
|
|
29
|
+
switch (kind) {
|
|
30
|
+
case 'square': return <rect x={x - r} y={y - r} width={r * 2} height={r * 2} fill={color} />;
|
|
31
|
+
case 'triangle': return <polygon points={`${x},${y - r - 1} ${x + r + 1},${y + r} ${x - r - 1},${y + r}`} fill={color} />;
|
|
32
|
+
case 'diamond': return <polygon points={`${x},${y - r - 1} ${x + r + 1},${y} ${x},${y + r + 1} ${x - r - 1},${y}`} fill={color} />;
|
|
33
|
+
case 'cross': return <path d={`M ${x - r} ${y - r} L ${x + r} ${y + r} M ${x + r} ${y - r} L ${x - r} ${y + r}`} stroke={color} strokeWidth={2} fill='none' />;
|
|
34
|
+
case 'ring': return <circle cx={x} cy={y} r={r} fill='none' stroke={color} strokeWidth={2} />;
|
|
35
|
+
default: return <circle cx={x} cy={y} r={r} fill={color} />;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface PlotProps {
|
|
40
|
+
kind: ChartKind;
|
|
41
|
+
categories: string[];
|
|
42
|
+
series: ChartSeries[];
|
|
43
|
+
width: number;
|
|
44
|
+
locale: string;
|
|
45
|
+
format: NumberFormat;
|
|
46
|
+
currency?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function CartesianPlot({ kind, categories, series, width, locale, format, currency }: PlotProps) {
|
|
50
|
+
const height = Math.max(180, Math.min(320, Math.round(width * 0.55)));
|
|
51
|
+
const plotWidth = width - margin.left - margin.right;
|
|
52
|
+
const plotHeight = height - margin.top - margin.bottom;
|
|
53
|
+
const scale = niceScale(dataMaximum(kind, series, categories.length));
|
|
54
|
+
const y = (value: number) => margin.top + plotHeight - (value / scale.maximum) * plotHeight;
|
|
55
|
+
const slot = plotWidth / Math.max(1, categories.length);
|
|
56
|
+
const patternId = useId();
|
|
57
|
+
const label = (value: number) => formatNumber(value, locale, format === NumberFormat.Number ? NumberFormat.Compact : format, currency);
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<svg className='foundation-chart-plot' viewBox={`0 0 ${width} ${height}`} width={width} height={height} aria-hidden='true' focusable='false'>
|
|
61
|
+
<defs>
|
|
62
|
+
{series.map((_, index) => index > 0 && index % 3 !== 0 && (
|
|
63
|
+
<pattern key={index} id={`${patternId}-${index}`} width='6' height='6' patternUnits='userSpaceOnUse' patternTransform={`rotate(${index % 2 === 0 ? 45 : -45})`}>
|
|
64
|
+
<rect width='6' height='6' fill={seriesColors[index % seriesColors.length]} opacity='0.35' />
|
|
65
|
+
<line x1='0' y1='0' x2='0' y2='6' stroke={seriesColors[index % seriesColors.length]} strokeWidth='2.5' />
|
|
66
|
+
</pattern>
|
|
67
|
+
))}
|
|
68
|
+
</defs>
|
|
69
|
+
{scale.ticks.map(tick => (
|
|
70
|
+
<g key={tick}>
|
|
71
|
+
<line className='foundation-chart-grid' x1={margin.left} x2={width - margin.right} y1={y(tick)} y2={y(tick)} />
|
|
72
|
+
<text className='foundation-chart-tick' x={margin.left - 6} y={y(tick)} textAnchor='end' dominantBaseline='middle'>{label(tick)}</text>
|
|
73
|
+
</g>
|
|
74
|
+
))}
|
|
75
|
+
{categories.map((category, index) => (
|
|
76
|
+
<text key={category + index} className='foundation-chart-tick' x={margin.left + slot * index + slot / 2} y={height - 8} textAnchor='middle'>{category}</text>
|
|
77
|
+
))}
|
|
78
|
+
{kind === ChartKind.Line && series.map((item, seriesIndex) => {
|
|
79
|
+
const color = seriesColors[seriesIndex % seriesColors.length];
|
|
80
|
+
const points = categories.map((_, index) => ({ x: margin.left + slot * index + slot / 2, y: y(item.values[index] ?? 0) }));
|
|
81
|
+
return (
|
|
82
|
+
<g key={item.name}>
|
|
83
|
+
<polyline points={points.map(point => `${point.x},${point.y}`).join(' ')} fill='none' stroke={color} strokeWidth='2.5' strokeDasharray={dashes[seriesIndex % dashes.length]} />
|
|
84
|
+
{points.map((point, index) => <MarkerShape key={index} kind={markers[seriesIndex % markers.length]} x={point.x} y={point.y} color={color} />)}
|
|
85
|
+
</g>
|
|
86
|
+
);
|
|
87
|
+
})}
|
|
88
|
+
{kind === ChartKind.Bar && series.map((item, seriesIndex) => {
|
|
89
|
+
const group = slot * 0.7;
|
|
90
|
+
const bar = group / series.length;
|
|
91
|
+
return (
|
|
92
|
+
<g key={item.name}>
|
|
93
|
+
{categories.map((_, index) => {
|
|
94
|
+
const value = Math.max(0, item.values[index] ?? 0);
|
|
95
|
+
return <rect key={index} className='foundation-chart-bar' x={margin.left + slot * index + (slot - group) / 2 + bar * seriesIndex} y={y(value)} width={Math.max(1, bar - 2)} height={margin.top + plotHeight - y(value)}
|
|
96
|
+
fill={fillFor(seriesIndex, patternId)} stroke={seriesColors[seriesIndex % seriesColors.length]} strokeWidth='1.5' />;
|
|
97
|
+
})}
|
|
98
|
+
</g>
|
|
99
|
+
);
|
|
100
|
+
})}
|
|
101
|
+
{kind === ChartKind.Stacked && categories.map((_, index) => {
|
|
102
|
+
let base = 0;
|
|
103
|
+
return (
|
|
104
|
+
<g key={index}>
|
|
105
|
+
{series.map((item, seriesIndex) => {
|
|
106
|
+
const value = Math.max(0, item.values[index] ?? 0);
|
|
107
|
+
const top = y(base + value);
|
|
108
|
+
const rect = <rect key={item.name} className='foundation-chart-bar' x={margin.left + slot * index + slot * 0.15} y={top} width={slot * 0.7} height={Math.max(0, y(base) - top)}
|
|
109
|
+
fill={fillFor(seriesIndex, patternId)} stroke={seriesColors[seriesIndex % seriesColors.length]} strokeWidth='1.5' />;
|
|
110
|
+
base += value;
|
|
111
|
+
return rect;
|
|
112
|
+
})}
|
|
113
|
+
</g>
|
|
114
|
+
);
|
|
115
|
+
})}
|
|
116
|
+
</svg>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function fillFor(seriesIndex: number, patternId: string): string {
|
|
121
|
+
return seriesIndex === 0 || seriesIndex % 3 === 0 ? seriesColors[seriesIndex % seriesColors.length] : `url(#${patternId}-${seriesIndex})`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function DonutPlot({ categories, values, width, locale }: { categories: string[]; values: number[]; width: number; locale: string }) {
|
|
125
|
+
const size = Math.max(160, Math.min(280, width));
|
|
126
|
+
const radius = size / 2 - 4;
|
|
127
|
+
const slices = donutSlices(values);
|
|
128
|
+
const patternId = useId();
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
<svg className='foundation-chart-plot' viewBox={`0 0 ${size} ${size}`} width={size} height={size} aria-hidden='true' focusable='false'>
|
|
132
|
+
<defs>
|
|
133
|
+
{values.map((_, index) => index > 0 && index % 3 !== 0 && (
|
|
134
|
+
<pattern key={index} id={`${patternId}-${index}`} width='6' height='6' patternUnits='userSpaceOnUse' patternTransform={`rotate(${index % 2 === 0 ? 45 : -45})`}>
|
|
135
|
+
<rect width='6' height='6' fill={seriesColors[index % seriesColors.length]} opacity='0.35' />
|
|
136
|
+
<line x1='0' y1='0' x2='0' y2='6' stroke={seriesColors[index % seriesColors.length]} strokeWidth='2.5' />
|
|
137
|
+
</pattern>
|
|
138
|
+
))}
|
|
139
|
+
</defs>
|
|
140
|
+
{slices.map(slice => (
|
|
141
|
+
<path key={slice.index} className='foundation-chart-slice' d={ringPath(size / 2, size / 2, radius, radius * 0.6, slice.start, slice.end)} fill={fillFor(slice.index, patternId)}
|
|
142
|
+
stroke={seriesColors[slice.index % seriesColors.length]} strokeWidth='1.5' data-share={formatNumber(slice.share * 100, locale, NumberFormat.Percent)}>
|
|
143
|
+
<title>{`${categories[slice.index] ?? ''}: ${formatNumber(slice.share * 100, locale, NumberFormat.Percent)}`}</title>
|
|
144
|
+
</path>
|
|
145
|
+
))}
|
|
146
|
+
</svg>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function FunnelPlot({ categories, values, width, locale, format, currency }: { categories: string[]; values: number[]; width: number; locale: string; format: NumberFormat; currency?: string }) {
|
|
151
|
+
const stages = funnelStages(values);
|
|
152
|
+
const barHeight = 34;
|
|
153
|
+
const gap = 6;
|
|
154
|
+
const height = stages.length * (barHeight + gap);
|
|
155
|
+
const patternId = useId();
|
|
156
|
+
|
|
157
|
+
return (
|
|
158
|
+
<svg className='foundation-chart-plot' viewBox={`0 0 ${width} ${height}`} width={width} height={height} aria-hidden='true' focusable='false'>
|
|
159
|
+
<defs>
|
|
160
|
+
{stages.map(stage => stage.index > 0 && stage.index % 3 !== 0 && (
|
|
161
|
+
<pattern key={stage.index} id={`${patternId}-${stage.index}`} width='6' height='6' patternUnits='userSpaceOnUse' patternTransform='rotate(45)'>
|
|
162
|
+
<rect width='6' height='6' fill={seriesColors[stage.index % seriesColors.length]} opacity='0.35' />
|
|
163
|
+
<line x1='0' y1='0' x2='0' y2='6' stroke={seriesColors[stage.index % seriesColors.length]} strokeWidth='2.5' />
|
|
164
|
+
</pattern>
|
|
165
|
+
))}
|
|
166
|
+
</defs>
|
|
167
|
+
{stages.map(stage => {
|
|
168
|
+
const stageWidth = Math.max(2, width * stage.widthRatio);
|
|
169
|
+
return (
|
|
170
|
+
<g key={stage.index}>
|
|
171
|
+
<rect className='foundation-chart-bar' x={(width - stageWidth) / 2} y={stage.index * (barHeight + gap)} width={stageWidth} height={barHeight} rx='4'
|
|
172
|
+
fill={fillFor(stage.index, patternId)} stroke={seriesColors[stage.index % seriesColors.length]} strokeWidth='1.5' />
|
|
173
|
+
<text className='foundation-chart-stage' x={width / 2} y={stage.index * (barHeight + gap) + barHeight / 2} textAnchor='middle' dominantBaseline='middle'>
|
|
174
|
+
{`${categories[stage.index] ?? ''} · ${formatNumber(stage.value, locale, format, currency)}`}
|
|
175
|
+
</text>
|
|
176
|
+
</g>
|
|
177
|
+
);
|
|
178
|
+
})}
|
|
179
|
+
</svg>
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The `chartWrapper` component: one chart - line, bar, stacked, donut or funnel - drawn as scalable vector graphics from the
|
|
185
|
+
* theme's tokens, with its own title, legend and a data table.
|
|
186
|
+
*
|
|
187
|
+
* `categories` name the points and `series` give a list of values for each. A donut and a funnel draw the first series, one slice or
|
|
188
|
+
* stage per category. The chart is never the only way to read the data: the figure is labelled, the drawing is hidden from assistive
|
|
189
|
+
* technology, and a table of the same numbers follows it - open it with "Chart data". Series are told apart by marker, dash or hatch as
|
|
190
|
+
* well as color, and the legend names each.
|
|
191
|
+
*/
|
|
192
|
+
export function ChartWrapper({ element, interactions }: RegisteredComponentProps) {
|
|
193
|
+
const kind = readChartKind(element.properties.kind);
|
|
194
|
+
const categories = readStrings(element, 'categories');
|
|
195
|
+
const series = readSeries(element.properties.series);
|
|
196
|
+
const hasData = series.some(item => item.values.length > 0) && categories.length > 0;
|
|
197
|
+
const state = readComponentState(element, hasData);
|
|
198
|
+
const locale = readLocale(element);
|
|
199
|
+
const format = readNumberFormat(element);
|
|
200
|
+
const currency = readOptionalString(element, 'currency');
|
|
201
|
+
const strings = readFoundationStrings(element);
|
|
202
|
+
const container = useRef<HTMLDivElement>(null);
|
|
203
|
+
const width = useElementWidth(container, 480);
|
|
204
|
+
const title = readOptionalString(element, 'title') ?? readOptionalString(element, 'label');
|
|
205
|
+
const first = series[0]?.values ?? [];
|
|
206
|
+
const single = kind === ChartKind.Donut || kind === ChartKind.Funnel;
|
|
207
|
+
const legend = single ? categories.map((name, index) => ({ name, index })) : series.map((item, index) => ({ name: item.name, index }));
|
|
208
|
+
const dataTableId = useId();
|
|
209
|
+
|
|
210
|
+
return (
|
|
211
|
+
<FoundationFrame element={{ ...element, properties: { ...element.properties, label: title } }} interactions={interactions} name='chart' state={state} skeleton={[30, 100, 100, 60]}>
|
|
212
|
+
{(state === ComponentState.Ready || state === ComponentState.Partial) && (
|
|
213
|
+
<figure className='foundation-chart-figure' data-kind={kind}>
|
|
214
|
+
{title && <figcaption className='foundation-chart-title'>{title}</figcaption>}
|
|
215
|
+
<div ref={container} className='foundation-chart-canvas'>
|
|
216
|
+
{kind === ChartKind.Donut ? <DonutPlot categories={categories} values={first} width={width} locale={locale} />
|
|
217
|
+
: kind === ChartKind.Funnel ? <FunnelPlot categories={categories} values={first} width={width} locale={locale} format={format} currency={currency} />
|
|
218
|
+
: <CartesianPlot kind={kind} categories={categories} series={series} width={width} locale={locale} format={format} currency={currency} />}
|
|
219
|
+
</div>
|
|
220
|
+
<ul className='foundation-chart-legend' aria-label={strings.chartSeries}>
|
|
221
|
+
{legend.map(entry => (
|
|
222
|
+
<li key={entry.index}>
|
|
223
|
+
<svg width='14' height='14' viewBox='0 0 14 14' aria-hidden='true'>
|
|
224
|
+
{kind === ChartKind.Line ? <MarkerShape kind={markers[entry.index % markers.length]} x={7} y={7} color={seriesColors[entry.index % seriesColors.length]} />
|
|
225
|
+
: <rect x='1' y='1' width='12' height='12' fill={seriesColors[entry.index % seriesColors.length]} opacity={entry.index === 0 || entry.index % 3 === 0 ? 1 : 0.5} stroke={seriesColors[entry.index % seriesColors.length]} strokeWidth='2' />}
|
|
226
|
+
</svg>
|
|
227
|
+
<span>{entry.name}</span>
|
|
228
|
+
</li>
|
|
229
|
+
))}
|
|
230
|
+
</ul>
|
|
231
|
+
<details className='foundation-chart-data'>
|
|
232
|
+
<summary>{strings.chartDataTable}</summary>
|
|
233
|
+
<table id={dataTableId}>
|
|
234
|
+
<caption className='foundation-visually-hidden'>{title ?? strings.chartDataTable}</caption>
|
|
235
|
+
<thead>
|
|
236
|
+
<tr><th scope='col'>{strings.chartCategory}</th>{(single ? [{ name: series[0]?.name ?? strings.chartSeries }] : series).map(item => <th key={item.name} scope='col'>{item.name}</th>)}</tr>
|
|
237
|
+
</thead>
|
|
238
|
+
<tbody>
|
|
239
|
+
{categories.map((category, index) => (
|
|
240
|
+
<tr key={category + index}>
|
|
241
|
+
<th scope='row'>{category}</th>
|
|
242
|
+
{(single ? [series[0]] : series).map(item => <td key={item?.name}>{formatNumber(item?.values[index] ?? 0, locale, format, currency)}</td>)}
|
|
243
|
+
</tr>
|
|
244
|
+
))}
|
|
245
|
+
</tbody>
|
|
246
|
+
</table>
|
|
247
|
+
</details>
|
|
248
|
+
</figure>
|
|
249
|
+
)}
|
|
250
|
+
</FoundationFrame>
|
|
251
|
+
);
|
|
252
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Copyright (c) Cratis. All rights reserved.
|
|
2
|
+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
3
|
+
|
|
4
|
+
import { KeyboardEvent, useEffect, useId, useMemo, useRef, useState } from 'react';
|
|
5
|
+
import { RegisteredComponentProps, useValueOutput } from '@cratis/scene.react';
|
|
6
|
+
import { ChooserColumn, moveColumn, readColumns, toggleColumn, visibleColumnIds } from './columnModel';
|
|
7
|
+
import { ComponentState, readComponentState } from './ComponentState';
|
|
8
|
+
import { FoundationIcon } from './FoundationIcon';
|
|
9
|
+
import { formatText, readFoundationStrings } from './foundationStrings';
|
|
10
|
+
import { FoundationFrame } from './StateFrame';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The `columnChooser` component: which columns a table shows, and in what order.
|
|
14
|
+
*
|
|
15
|
+
* `columns` lists what could be shown (id, label, whether it starts visible, and whether it is locked - the identifying column is). The
|
|
16
|
+
* chooser is a button that discloses a list of checkboxes; each row also has move up and move down buttons, so ordering needs no
|
|
17
|
+
* drag-and-drop. Its `value` output is the ordered list of visible ids, which the table binds its columns to. Escape closes the list and
|
|
18
|
+
* returns focus to the button.
|
|
19
|
+
*/
|
|
20
|
+
export function ColumnChooser({ element, interactions, bindingOutputs }: RegisteredComponentProps) {
|
|
21
|
+
const supplied = useMemo(() => readColumns(element.properties.columns), [element.properties.columns]);
|
|
22
|
+
const suppliedKey = JSON.stringify(supplied);
|
|
23
|
+
const [columns, setColumns] = useState<ChooserColumn[]>(supplied);
|
|
24
|
+
const [open, setOpen] = useState(false);
|
|
25
|
+
const strings = readFoundationStrings(element);
|
|
26
|
+
const state = readComponentState(element, supplied.length > 0);
|
|
27
|
+
const panelId = useId();
|
|
28
|
+
const button = useRef<HTMLButtonElement>(null);
|
|
29
|
+
|
|
30
|
+
useEffect(() => setColumns(supplied), [suppliedKey]);
|
|
31
|
+
useValueOutput(bindingOutputs, visibleColumnIds(columns));
|
|
32
|
+
|
|
33
|
+
const apply = (next: ChooserColumn[]) => { setColumns(next); interactions?.onChange?.(); };
|
|
34
|
+
const close = (event: KeyboardEvent) => {
|
|
35
|
+
if (event.key !== 'Escape' || !open) return;
|
|
36
|
+
setOpen(false);
|
|
37
|
+
button.current?.focus();
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<FoundationFrame element={element} interactions={interactions} name='column-chooser' state={state} skeleton={[50]}>
|
|
42
|
+
{state !== ComponentState.Loading && (
|
|
43
|
+
<div className='foundation-popover-host' onKeyDown={close}>
|
|
44
|
+
<button ref={button} type='button' className='foundation-button' data-emphasis='secondary' aria-expanded={open} aria-controls={panelId} onClick={() => setOpen(!open)}>
|
|
45
|
+
<FoundationIcon name='columns' />
|
|
46
|
+
<span>{strings.columnsChoose}</span>
|
|
47
|
+
<span className='foundation-count'>{formatText(strings.columnsShown, { shown: visibleColumnIds(columns).length, total: columns.length })}</span>
|
|
48
|
+
</button>
|
|
49
|
+
<div id={panelId} className='foundation-popover' hidden={!open}>
|
|
50
|
+
<ul className='foundation-list' aria-label={strings.columnsChoose}>
|
|
51
|
+
{columns.map((column, index) => (
|
|
52
|
+
<li key={column.id} className='foundation-list-row'>
|
|
53
|
+
<label className='foundation-check'>
|
|
54
|
+
<input type='checkbox' checked={column.visible} disabled={column.locked} onChange={() => apply(toggleColumn(columns, index))} />
|
|
55
|
+
<span>{column.label}</span>
|
|
56
|
+
{column.locked && <span className='foundation-count'>({strings.columnsLocked})</span>}
|
|
57
|
+
</label>
|
|
58
|
+
<button type='button' className='foundation-icon-button' disabled={index === 0} aria-label={formatText(strings.columnsMoveUp, { name: column.label })} onClick={() => apply(moveColumn(columns, index, -1))}>
|
|
59
|
+
<FoundationIcon name='arrow-up' />
|
|
60
|
+
</button>
|
|
61
|
+
<button type='button' className='foundation-icon-button' disabled={index === columns.length - 1} aria-label={formatText(strings.columnsMoveDown, { name: column.label })} onClick={() => apply(moveColumn(columns, index, 1))}>
|
|
62
|
+
<FoundationIcon name='arrow-down' />
|
|
63
|
+
</button>
|
|
64
|
+
</li>
|
|
65
|
+
))}
|
|
66
|
+
</ul>
|
|
67
|
+
<button type='button' className='foundation-link' onClick={() => apply(columns.map(column => ({ ...column, visible: true })))}>{strings.columnsReset}</button>
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|
|
70
|
+
)}
|
|
71
|
+
</FoundationFrame>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
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 { ExternalComponent } from '@cratis/scene.model';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The visual states every data-showing foundation component defines (spec Part B 1.4).
|
|
8
|
+
*
|
|
9
|
+
* A template is only worth choosing if it looks right in all of them: an application spends real time loading, empty, failing
|
|
10
|
+
* and not allowed, and a component that only draws its populated self has not been finished.
|
|
11
|
+
*/
|
|
12
|
+
export enum ComponentState {
|
|
13
|
+
/** The data is there and drawn. */
|
|
14
|
+
Ready = 'ready',
|
|
15
|
+
|
|
16
|
+
/** The data has not answered yet: a skeleton in the shape of the content, never a spinner alone. */
|
|
17
|
+
Loading = 'loading',
|
|
18
|
+
|
|
19
|
+
/** The data answered with nothing: an explanation and the primary action. */
|
|
20
|
+
Empty = 'empty',
|
|
21
|
+
|
|
22
|
+
/** The data could not be loaded: a message and a retry. */
|
|
23
|
+
Error = 'error',
|
|
24
|
+
|
|
25
|
+
/** The user may not see the data: what is missing, and nothing of the data. */
|
|
26
|
+
NoPermission = 'noPermission',
|
|
27
|
+
|
|
28
|
+
/** Some regions loaded and some failed: what loaded, plus which parts did not. */
|
|
29
|
+
Partial = 'partial',
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Every state, in the order a preview or a story lists them. */
|
|
33
|
+
export const componentStates: readonly ComponentState[] = Object.values(ComponentState);
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Reads the state a component is in from its properties.
|
|
37
|
+
*
|
|
38
|
+
* An explicit `state` property wins, which is how a preview forces a state. Otherwise the state follows the properties a bound
|
|
39
|
+
* query already publishes - the same `loading` and `error` the preview states use for tables - plus `forbidden` and
|
|
40
|
+
* `failedRegions`, and falls to Empty when the component has nothing to show.
|
|
41
|
+
*
|
|
42
|
+
* @param element The element being rendered.
|
|
43
|
+
* @param hasData Whether the component has anything to draw when it is ready.
|
|
44
|
+
*/
|
|
45
|
+
export function readComponentState(element: ExternalComponent, hasData: boolean): ComponentState {
|
|
46
|
+
const { state, forbidden, error, loading, failedRegions } = element.properties;
|
|
47
|
+
if (typeof state === 'string' && (componentStates as readonly string[]).includes(state)) return state as ComponentState;
|
|
48
|
+
if (forbidden === true) return ComponentState.NoPermission;
|
|
49
|
+
if (typeof error === 'string' && error.length > 0) return ComponentState.Error;
|
|
50
|
+
if (loading === true) return ComponentState.Loading;
|
|
51
|
+
if (Array.isArray(failedRegions) && failedRegions.length > 0) return ComponentState.Partial;
|
|
52
|
+
return hasData ? ComponentState.Ready : ComponentState.Empty;
|
|
53
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Copyright (c) Cratis. All rights reserved.
|
|
2
|
+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
3
|
+
|
|
4
|
+
import { RegisteredComponentProps } from '@cratis/scene.react';
|
|
5
|
+
import { EmptyStateView } from './StateFrame';
|
|
6
|
+
import { readFoundationStrings } from './foundationStrings';
|
|
7
|
+
import { readLocale } from './formatting';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The empty state as a component of its own: an icon, a headline, a hint and one primary action, embeddable in any region.
|
|
11
|
+
*
|
|
12
|
+
* Every other foundation component shows this same view when it has no data, so a screen reads the same wherever nothing is
|
|
13
|
+
* found. Placed on its own it is what a template puts in a region that has nothing to say yet. The action starts whatever the
|
|
14
|
+
* element's behaviors say, and honours the `actionPermission` rule, so an empty list never offers a creation the user may not do.
|
|
15
|
+
*/
|
|
16
|
+
export function EmptyState({ element, interactions }: RegisteredComponentProps) {
|
|
17
|
+
return (
|
|
18
|
+
<section className='foundation foundation-empty-state' data-scene-id={element.id} data-state='empty' lang={readLocale(element)}>
|
|
19
|
+
<EmptyStateView element={element} interactions={interactions} strings={readFoundationStrings(element)} />
|
|
20
|
+
</section>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
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 { ComponentState, readComponentState } from './ComponentState';
|
|
7
|
+
import { ActionButton } from './ActionButton';
|
|
8
|
+
import {
|
|
9
|
+
FilterCondition, FilterField, emptyFilter, FilterFieldType, FilterGroup, countActiveConditions, filterOperators, maximumFilterDepth, newCondition,
|
|
10
|
+
normalizeFilter, readFilterFields, updateGroupAt, valuelessOperators,
|
|
11
|
+
} from './filterModel';
|
|
12
|
+
import { FoundationStrings, formatText, readFoundationStrings } from './foundationStrings';
|
|
13
|
+
import { FoundationFrame } from './StateFrame';
|
|
14
|
+
import { readPermissionRule } from './PermissionRule';
|
|
15
|
+
|
|
16
|
+
const operatorText = (strings: FoundationStrings, operator: string): string => {
|
|
17
|
+
const names: Record<string, string> = {
|
|
18
|
+
contains: strings.opContains, equals: strings.opEquals, notEquals: strings.opNotEquals, startsWith: strings.opStartsWith, isEmpty: strings.opIsEmpty,
|
|
19
|
+
greater: strings.opGreater, greaterOrEqual: strings.opGreaterOrEqual, less: strings.opLess, lessOrEqual: strings.opLessOrEqual,
|
|
20
|
+
before: strings.opBefore, after: strings.opAfter, on: strings.opOn, is: strings.opIs, isNot: strings.opIsNot, true: strings.opTrue, false: strings.opFalse,
|
|
21
|
+
};
|
|
22
|
+
return names[operator] ?? operator;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
interface GroupEditorProps {
|
|
26
|
+
group: FilterGroup;
|
|
27
|
+
path: number[];
|
|
28
|
+
fields: FilterField[];
|
|
29
|
+
strings: FoundationStrings;
|
|
30
|
+
locked: boolean;
|
|
31
|
+
change(path: number[], change: (group: FilterGroup) => FilterGroup): void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function ValueInput({ field, condition, strings, label, disabled, onChange }: { field: FilterField | undefined; condition: FilterCondition; strings: FoundationStrings; label: string; disabled: boolean; onChange(value: string): void }) {
|
|
35
|
+
if (valuelessOperators.includes(condition.operator)) return undefined;
|
|
36
|
+
|
|
37
|
+
if (field?.type === FilterFieldType.Select) {
|
|
38
|
+
return (
|
|
39
|
+
<select aria-label={`${strings.filterValue}, ${label}`} value={condition.value} disabled={disabled} onChange={event => onChange(event.target.value)}>
|
|
40
|
+
<option value=''>—</option>
|
|
41
|
+
{field.options.map(option => <option key={option} value={option}>{option}</option>)}
|
|
42
|
+
</select>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return <input aria-label={`${strings.filterValue}, ${label}`} type={field?.type === FilterFieldType.Number ? 'number' : field?.type === FilterFieldType.Date ? 'date' : 'text'} value={condition.value} disabled={disabled} onChange={event => onChange(event.target.value)} />;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function GroupEditor({ group, path, fields, strings, locked, change }: GroupEditorProps) {
|
|
50
|
+
const label = path.length === 0 ? strings.filterMatchAll.replace(/ \(.*\)$/, '') : formatText(strings.filterGroup, { n: path.join('.') });
|
|
51
|
+
const depth = path.length + 1;
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<div className='foundation-filter-group' role='group' aria-label={path.length === 0 ? formatText(countActiveConditions(group) === 1 ? strings.filterSummaryOne : strings.filterSummary, { count: countActiveConditions(group) }) : label} data-depth={depth}>
|
|
55
|
+
<div className='foundation-filter-match'>
|
|
56
|
+
<select aria-label={strings.filterMatchAll} value={group.operator} disabled={locked} onChange={event => change(path, current => ({ ...current, operator: event.target.value === 'or' ? 'or' : 'and' }))}>
|
|
57
|
+
<option value='and'>{strings.filterMatchAll}</option>
|
|
58
|
+
<option value='or'>{strings.filterMatchAny}</option>
|
|
59
|
+
</select>
|
|
60
|
+
{path.length > 0 && !locked && (
|
|
61
|
+
<button type='button' className='foundation-link' onClick={() => change(path.slice(0, -1), parent => ({ ...parent, groups: parent.groups.filter((_, index) => index !== path[path.length - 1]) }))}>{strings.filterRemoveGroup}</button>
|
|
62
|
+
)}
|
|
63
|
+
</div>
|
|
64
|
+
{group.conditions.map((condition, index) => {
|
|
65
|
+
const field = fields.find(candidate => candidate.name === condition.field);
|
|
66
|
+
const conditionLabel = formatText(strings.filterCondition, { n: index + 1 });
|
|
67
|
+
return (
|
|
68
|
+
<div className='foundation-filter-row' role='group' aria-label={conditionLabel} key={index}>
|
|
69
|
+
<select aria-label={`${strings.filterField}, ${conditionLabel}`} value={condition.field} disabled={locked}
|
|
70
|
+
onChange={event => {
|
|
71
|
+
const next = fields.find(candidate => candidate.name === event.target.value);
|
|
72
|
+
change(path, current => ({ ...current, conditions: current.conditions.map((existing, position) => (position === index
|
|
73
|
+
? { field: event.target.value, operator: next ? filterOperators[next.type][0] : existing.operator, value: '' } : existing)) }));
|
|
74
|
+
}}>
|
|
75
|
+
{fields.map(candidate => <option key={candidate.name} value={candidate.name}>{candidate.label}</option>)}
|
|
76
|
+
</select>
|
|
77
|
+
<select aria-label={`${strings.filterOperator}, ${conditionLabel}`} value={condition.operator} disabled={locked}
|
|
78
|
+
onChange={event => change(path, current => ({ ...current, conditions: current.conditions.map((existing, position) => (position === index ? { ...existing, operator: event.target.value } : existing)) }))}>
|
|
79
|
+
{filterOperators[field?.type ?? FilterFieldType.Text].map(operator => <option key={operator} value={operator}>{operatorText(strings, operator)}</option>)}
|
|
80
|
+
</select>
|
|
81
|
+
<ValueInput field={field} condition={condition} strings={strings} label={conditionLabel} disabled={locked}
|
|
82
|
+
onChange={value => change(path, current => ({ ...current, conditions: current.conditions.map((existing, position) => (position === index ? { ...existing, value } : existing)) }))} />
|
|
83
|
+
{!locked && (
|
|
84
|
+
<button type='button' className='foundation-icon-button' aria-label={`${strings.filterRemove}, ${conditionLabel}`}
|
|
85
|
+
onClick={() => change(path, current => ({ ...current, conditions: current.conditions.filter((_, position) => position !== index) }))}>×</button>
|
|
86
|
+
)}
|
|
87
|
+
</div>
|
|
88
|
+
);
|
|
89
|
+
})}
|
|
90
|
+
{group.groups.map((nested, index) => <GroupEditor key={index} group={nested} path={[...path, index]} fields={fields} strings={strings} locked={locked} change={change} />)}
|
|
91
|
+
{!locked && (
|
|
92
|
+
<div className='foundation-filter-actions'>
|
|
93
|
+
<button type='button' className='foundation-link' onClick={() => change(path, current => ({ ...current, conditions: [...current.conditions, newCondition(fields)] }))}>{strings.filterAdd}</button>
|
|
94
|
+
{depth < maximumFilterDepth && (
|
|
95
|
+
<button type='button' className='foundation-link' onClick={() => change(path, current => ({ ...current, groups: [...current.groups, { operator: 'and', conditions: [newCondition(fields)], groups: [] }] }))}>{strings.filterAddGroup}</button>
|
|
96
|
+
)}
|
|
97
|
+
</div>
|
|
98
|
+
)}
|
|
99
|
+
</div>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The `filterBuilder` component: field, operator and value conditions joined by AND or OR, in groups that nest up to three deep.
|
|
105
|
+
*
|
|
106
|
+
* The filter is the component's `value` output - `{ operator, conditions, groups }` - which a list screen binds its query to; the
|
|
107
|
+
* component itself filters nothing. `fields` describe what can be filtered (name, label, type, and options for a select), and
|
|
108
|
+
* the operators on offer follow each field's type. A filter bound in through `value` is read back as the user left it: conditions on
|
|
109
|
+
* fields that no longer exist are dropped rather than shown broken. The component is the filter panel of a list on a desktop and
|
|
110
|
+
* the body of the filter dialog on a phone.
|
|
111
|
+
*/
|
|
112
|
+
export function FilterBuilder({ element, interactions, bindingOutputs }: RegisteredComponentProps) {
|
|
113
|
+
const fields = useMemo(() => readFilterFields(element.properties.fields), [element.properties.fields]);
|
|
114
|
+
const supplied = useMemo(() => normalizeFilter(element.properties.filter, fields), [element.properties.filter, fields]);
|
|
115
|
+
const [filter, setFilter] = useState<FilterGroup>(supplied);
|
|
116
|
+
const strings = readFoundationStrings(element);
|
|
117
|
+
const suppliedKey = JSON.stringify(supplied);
|
|
118
|
+
const state = readComponentState(element, fields.length > 0);
|
|
119
|
+
const locked = readPermissionRule(element, 'editPermission') !== 'enabled';
|
|
120
|
+
|
|
121
|
+
useEffect(() => setFilter(supplied), [suppliedKey]);
|
|
122
|
+
useValueOutput(bindingOutputs, filter);
|
|
123
|
+
|
|
124
|
+
const change = (path: number[], apply: (group: FilterGroup) => FilterGroup) => {
|
|
125
|
+
const next = updateGroupAt(filter, path, apply);
|
|
126
|
+
setFilter(next);
|
|
127
|
+
interactions?.onChange?.();
|
|
128
|
+
};
|
|
129
|
+
const active = countActiveConditions(filter);
|
|
130
|
+
|
|
131
|
+
return (
|
|
132
|
+
<FoundationFrame element={element} interactions={interactions} name='filter-builder' state={state} skeleton={[100, 100]}>
|
|
133
|
+
{state !== ComponentState.Loading && (
|
|
134
|
+
<>
|
|
135
|
+
<GroupEditor group={filter} path={[]} fields={fields} strings={strings} locked={locked} change={change} />
|
|
136
|
+
<div className='foundation-filter-footer'>
|
|
137
|
+
<span className='foundation-filter-summary' aria-live='polite'>{active === 1 ? strings.filterSummaryOne : formatText(strings.filterSummary, { count: active })}</span>
|
|
138
|
+
{active > 0 && !locked && <ActionButton emphasis='quiet' disabledReason={strings.disabledNoPermission} onClick={() => { setFilter(emptyFilter()); interactions?.onChange?.(); }}>{strings.filterClear}</ActionButton>}
|
|
139
|
+
</div>
|
|
140
|
+
</>
|
|
141
|
+
)}
|
|
142
|
+
</FoundationFrame>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
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 type { Meta, StoryObj } from '@storybook/react';
|
|
5
|
+
import { ComponentName } from '../ComponentName';
|
|
6
|
+
import { FoundationStates } from './FoundationStates';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The foundation components (spec Part B 4), each in every standard state - ready, loading, empty, error, no permission and
|
|
10
|
+
* partial - in the light and the dark theme. Each component also has a right-to-left story.
|
|
11
|
+
*/
|
|
12
|
+
const meta = {
|
|
13
|
+
title: 'Blueprint/Foundation components',
|
|
14
|
+
component: FoundationStates,
|
|
15
|
+
parameters: { layout: 'fullscreen' },
|
|
16
|
+
} satisfies Meta<typeof FoundationStates>;
|
|
17
|
+
|
|
18
|
+
export default meta;
|
|
19
|
+
|
|
20
|
+
type Story = StoryObj<typeof meta>;
|
|
21
|
+
|
|
22
|
+
export const EmptyStateLight: Story = { args: { component: ComponentName.EmptyState, theme: 'light' } };
|
|
23
|
+
export const EmptyStateDark: Story = { args: { component: ComponentName.EmptyState, theme: 'dark' } };
|
|
24
|
+
export const StatCardLight: Story = { args: { component: ComponentName.StatCard, theme: 'light' } };
|
|
25
|
+
export const StatCardDark: Story = { args: { component: ComponentName.StatCard, theme: 'dark' } };
|
|
26
|
+
export const StatCardRightToLeft: Story = { args: { component: ComponentName.StatCard, theme: 'light', rightToLeft: true, properties: { label: 'خط الأنابيب', locale: 'ar-EG' } } };
|
|
27
|
+
export const TrendChipLight: Story = { args: { component: ComponentName.TrendChip, theme: 'light' } };
|
|
28
|
+
export const TrendChipDark: Story = { args: { component: ComponentName.TrendChip, theme: 'dark' } };
|
|
29
|
+
export const StatusChipLight: Story = { args: { component: ComponentName.StatusChip, theme: 'light' } };
|
|
30
|
+
export const StatusChipDark: Story = { args: { component: ComponentName.StatusChip, theme: 'dark' } };
|
|
31
|
+
export const ChartLineLight: Story = { args: { component: ComponentName.ChartWrapper, theme: 'light' } };
|
|
32
|
+
export const ChartLineDark: Story = { args: { component: ComponentName.ChartWrapper, theme: 'dark' } };
|
|
33
|
+
export const ChartBarLight: Story = { args: { component: ComponentName.ChartWrapper, theme: 'light', properties: { kind: 'bar' } } };
|
|
34
|
+
export const ChartBarDark: Story = { args: { component: ComponentName.ChartWrapper, theme: 'dark', properties: { kind: 'bar' } } };
|
|
35
|
+
export const ChartStackedLight: Story = { args: { component: ComponentName.ChartWrapper, theme: 'light', properties: { kind: 'stacked' } } };
|
|
36
|
+
export const ChartStackedDark: Story = { args: { component: ComponentName.ChartWrapper, theme: 'dark', properties: { kind: 'stacked' } } };
|
|
37
|
+
export const ChartDonutLight: Story = { args: { component: ComponentName.ChartWrapper, theme: 'light', properties: { kind: 'donut' } } };
|
|
38
|
+
export const ChartDonutDark: Story = { args: { component: ComponentName.ChartWrapper, theme: 'dark', properties: { kind: 'donut' } } };
|
|
39
|
+
export const ChartFunnelLight: Story = { args: { component: ComponentName.ChartWrapper, theme: 'light', properties: { kind: 'funnel', categories: ['Leads', 'Qualified', 'Proposal', 'Won'], series: [{ name: 'Deals', values: [400, 220, 90, 40] }] } } };
|
|
40
|
+
export const ChartFunnelDark: Story = { args: { component: ComponentName.ChartWrapper, theme: 'dark', properties: { kind: 'funnel', categories: ['Leads', 'Qualified', 'Proposal', 'Won'], series: [{ name: 'Deals', values: [400, 220, 90, 40] }] } } };
|
|
41
|
+
export const FilterBuilderLight: Story = { args: { component: ComponentName.FilterBuilder, theme: 'light' } };
|
|
42
|
+
export const FilterBuilderDark: Story = { args: { component: ComponentName.FilterBuilder, theme: 'dark' } };
|
|
43
|
+
export const ColumnChooserLight: Story = { args: { component: ComponentName.ColumnChooser, theme: 'light' } };
|
|
44
|
+
export const ColumnChooserDark: Story = { args: { component: ComponentName.ColumnChooser, theme: 'dark' } };
|
|
45
|
+
export const SavedViewsMenuLight: Story = { args: { component: ComponentName.SavedViewsMenu, theme: 'light' } };
|
|
46
|
+
export const SavedViewsMenuDark: Story = { args: { component: ComponentName.SavedViewsMenu, theme: 'dark' } };
|