@cratis/scene.blueprint.default 4.32.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 +12 -0
- package/defaultBlueprintComponents.ts +5 -1
- package/dist/cjs/ComponentName.js +4 -0
- package/dist/cjs/ComponentName.js.map +1 -1
- package/dist/cjs/defaultBlueprint.js +1 -1
- package/dist/cjs/defaultBlueprintComponentMetadata.js +2 -2
- package/dist/cjs/defaultBlueprintComponents.js +10 -2
- package/dist/cjs/defaultBlueprintComponents.js.map +1 -1
- 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/FilterBuilder.js +307 -0
- package/dist/cjs/foundation/FilterBuilder.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/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/foundationComponentMetadata.js +28 -0
- package/dist/cjs/foundation/foundationComponentMetadata.js.map +1 -1
- package/dist/cjs/foundation/foundationDescriptors.js +153 -0
- package/dist/cjs/foundation/foundationDescriptors.js.map +1 -1
- package/dist/cjs/foundation/foundationSamples.js +130 -0
- package/dist/cjs/foundation/foundationSamples.js.map +1 -1
- package/dist/cjs/foundation/foundationStrings.js +25 -0
- package/dist/cjs/foundation/foundationStrings.js.map +1 -1
- 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 +38 -0
- package/dist/cjs/index.js.map +1 -1
- package/dist/esm/ComponentName.d.ts +5 -1
- package/dist/esm/ComponentName.d.ts.map +1 -1
- package/dist/esm/ComponentName.js +4 -0
- package/dist/esm/ComponentName.js.map +1 -1
- package/dist/esm/defaultBlueprint.js +1 -1
- package/dist/esm/defaultBlueprintComponentMetadata.js +2 -2
- package/dist/esm/defaultBlueprintComponents.d.ts.map +1 -1
- package/dist/esm/defaultBlueprintComponents.js +10 -2
- package/dist/esm/defaultBlueprintComponents.js.map +1 -1
- 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/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 +16 -0
- package/dist/esm/foundation/Foundation.stories.d.ts.map +1 -1
- package/dist/esm/foundation/Foundation.stories.js +16 -0
- package/dist/esm/foundation/Foundation.stories.js.map +1 -1
- 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/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/foundationComponentMetadata.d.ts.map +1 -1
- package/dist/esm/foundation/foundationComponentMetadata.js +28 -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 +154 -1
- 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 +130 -0
- package/dist/esm/foundation/foundationSamples.js.map +1 -1
- package/dist/esm/foundation/foundationStrings.d.ts +25 -0
- package/dist/esm/foundation/foundationStrings.d.ts.map +1 -1
- package/dist/esm/foundation/foundationStrings.js +25 -0
- package/dist/esm/foundation/foundationStrings.js.map +1 -1
- package/dist/esm/foundation/index.d.ts +9 -0
- package/dist/esm/foundation/index.d.ts.map +1 -1
- package/dist/esm/foundation/index.js +9 -0
- package/dist/esm/foundation/index.js.map +1 -1
- 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/index.js +9 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/tsconfig.tsbuildinfo +1 -1
- package/foundation/ChartWrapper.tsx +252 -0
- package/foundation/ColumnChooser.tsx +73 -0
- package/foundation/FilterBuilder.tsx +144 -0
- package/foundation/Foundation.stories.tsx +16 -0
- package/foundation/SavedViewsMenu.tsx +94 -0
- package/foundation/chartGeometry.ts +108 -0
- package/foundation/columnModel.ts +39 -0
- package/foundation/filterModel.ts +111 -0
- package/foundation/foundationComponentMetadata.ts +4 -0
- package/foundation/foundationDescriptors.ts +86 -1
- package/foundation/foundationSamples.ts +19 -0
- package/foundation/foundationStrings.ts +25 -0
- package/foundation/index.ts +9 -0
- package/foundation/useElementWidth.ts +31 -0
- package/foundation/viewModel.ts +21 -0
- package/package.json +8 -8
- package/shell/layout.css +285 -0
|
@@ -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,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
|
+
}
|
|
@@ -28,3 +28,19 @@ export const TrendChipLight: Story = { args: { component: ComponentName.TrendChi
|
|
|
28
28
|
export const TrendChipDark: Story = { args: { component: ComponentName.TrendChip, theme: 'dark' } };
|
|
29
29
|
export const StatusChipLight: Story = { args: { component: ComponentName.StatusChip, theme: 'light' } };
|
|
30
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' } };
|
|
@@ -0,0 +1,94 @@
|
|
|
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 { ComponentState, readComponentState } from './ComponentState';
|
|
7
|
+
import { FoundationIcon } from './FoundationIcon';
|
|
8
|
+
import { readFoundationStrings } from './foundationStrings';
|
|
9
|
+
import { PermissionRule, readPermissionRule } from './PermissionRule';
|
|
10
|
+
import { FoundationFrame } from './StateFrame';
|
|
11
|
+
import { readOptionalString } from '../shell/elementProperties';
|
|
12
|
+
import { readViews } from './viewModel';
|
|
13
|
+
|
|
14
|
+
/** The output a menu action is published under: `save` or `manage`, which the host acts on. */
|
|
15
|
+
export const savedViewActionOutput = 'action';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The `savedViewsMenu` component: a list's saved views as a menu button, with the current view as its label.
|
|
19
|
+
*
|
|
20
|
+
* `views` are the choices (id, label, description) and `activeView` the one in force. Choosing a view publishes its id as the `value`
|
|
21
|
+
* output and runs the behaviors attached to the element, so a screen binds its query to the view. The menu follows the ARIA menu
|
|
22
|
+
* pattern: arrows, Home and End move, Enter chooses, Escape closes and returns focus to the button. "Save current view" and
|
|
23
|
+
* "Manage views" publish `save` or `manage` on the `action` output, and each honours its own permission rule (`savePermission`,
|
|
24
|
+
* `managePermission`): hidden, or disabled with the reason.
|
|
25
|
+
*/
|
|
26
|
+
export function SavedViewsMenu({ element, interactions, bindingOutputs }: RegisteredComponentProps) {
|
|
27
|
+
const views = useMemo(() => readViews(element.properties.views), [element.properties.views]);
|
|
28
|
+
const suppliedActive = readOptionalString(element, 'activeView') ?? views[0]?.id;
|
|
29
|
+
const [active, setActive] = useState<string | undefined>(suppliedActive);
|
|
30
|
+
const [open, setOpen] = useState(false);
|
|
31
|
+
const strings = readFoundationStrings(element);
|
|
32
|
+
const state = readComponentState(element, views.length > 0);
|
|
33
|
+
const menuId = useId();
|
|
34
|
+
const button = useRef<HTMLButtonElement>(null);
|
|
35
|
+
const menu = useRef<HTMLDivElement>(null);
|
|
36
|
+
const rules = { save: readPermissionRule(element, 'savePermission'), manage: readPermissionRule(element, 'managePermission') };
|
|
37
|
+
|
|
38
|
+
useEffect(() => setActive(suppliedActive), [suppliedActive]);
|
|
39
|
+
useValueOutput(bindingOutputs, active);
|
|
40
|
+
useEffect(() => { if (open) menu.current?.querySelector<HTMLElement>('[role^="menuitem"]:not([aria-disabled="true"])')?.focus(); }, [open]);
|
|
41
|
+
|
|
42
|
+
const items = () => Array.from(menu.current?.querySelectorAll<HTMLElement>('[role^="menuitem"]:not([aria-disabled="true"])') ?? []);
|
|
43
|
+
const move = (event: KeyboardEvent) => {
|
|
44
|
+
const entries = items();
|
|
45
|
+
const index = entries.indexOf(document.activeElement as HTMLElement);
|
|
46
|
+
const target = event.key === 'ArrowDown' ? (index + 1) % entries.length : event.key === 'ArrowUp' ? (index - 1 + entries.length) % entries.length
|
|
47
|
+
: event.key === 'Home' ? 0 : event.key === 'End' ? entries.length - 1 : -1;
|
|
48
|
+
|
|
49
|
+
if (target >= 0) { event.preventDefault(); entries[target]?.focus(); }
|
|
50
|
+
if (event.key === 'Escape') { setOpen(false); button.current?.focus(); }
|
|
51
|
+
if (event.key === 'Tab') setOpen(false);
|
|
52
|
+
};
|
|
53
|
+
const choose = (id: string) => { setActive(id); setOpen(false); button.current?.focus(); interactions?.onSelect?.(); };
|
|
54
|
+
const run = (action: 'save' | 'manage') => { bindingOutputs?.setOutput(savedViewActionOutput, action); setOpen(false); button.current?.focus(); interactions?.onClick?.({ stopPropagation: () => undefined }); };
|
|
55
|
+
const label = views.find(view => view.id === active)?.label ?? strings.viewsDefault;
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<FoundationFrame element={element} interactions={interactions} name='saved-views' state={state} skeleton={[45]}>
|
|
59
|
+
{state !== ComponentState.Loading && (
|
|
60
|
+
<div className='foundation-popover-host'>
|
|
61
|
+
<button ref={button} type='button' className='foundation-button' data-emphasis='secondary' aria-haspopup='menu' aria-expanded={open} aria-controls={menuId}
|
|
62
|
+
onClick={() => setOpen(!open)} onKeyDown={event => { if (event.key === 'ArrowDown' && !open) { event.preventDefault(); setOpen(true); } }}>
|
|
63
|
+
<span className='foundation-visually-hidden'>{`${strings.viewsLabel}: `}</span>
|
|
64
|
+
<span>{label}</span>
|
|
65
|
+
<FoundationIcon name='chevron-down' />
|
|
66
|
+
</button>
|
|
67
|
+
<div id={menuId} ref={menu} className='foundation-popover' role='menu' aria-label={strings.viewsLabel} hidden={!open} onKeyDown={move}>
|
|
68
|
+
{views.length === 0 && <span className='foundation-count'>{strings.viewsNone}</span>}
|
|
69
|
+
{views.map(view => (
|
|
70
|
+
<button key={view.id} type='button' role='menuitemradio' aria-checked={view.id === active} className='foundation-menu-item' tabIndex={-1} onClick={() => choose(view.id)}>
|
|
71
|
+
<span className='foundation-menu-check'>{view.id === active && <FoundationIcon name='check' />}</span>
|
|
72
|
+
<span>{view.label}</span>
|
|
73
|
+
{view.description && <span className='foundation-count'>{view.description}</span>}
|
|
74
|
+
</button>
|
|
75
|
+
))}
|
|
76
|
+
{(rules.save !== PermissionRule.Hidden || rules.manage !== PermissionRule.Hidden) && <hr className='foundation-separator' />}
|
|
77
|
+
{rules.save !== PermissionRule.Hidden && (
|
|
78
|
+
<button type='button' role='menuitem' className='foundation-menu-item' tabIndex={-1} aria-disabled={rules.save === PermissionRule.Disabled || undefined}
|
|
79
|
+
title={rules.save === PermissionRule.Disabled ? strings.disabledNoPermission : undefined} onClick={rules.save === PermissionRule.Enabled ? () => run('save') : undefined}>
|
|
80
|
+
<span className='foundation-menu-check' /><span>{strings.viewsSave}</span>
|
|
81
|
+
</button>
|
|
82
|
+
)}
|
|
83
|
+
{rules.manage !== PermissionRule.Hidden && (
|
|
84
|
+
<button type='button' role='menuitem' className='foundation-menu-item' tabIndex={-1} aria-disabled={rules.manage === PermissionRule.Disabled || undefined}
|
|
85
|
+
title={rules.manage === PermissionRule.Disabled ? strings.disabledNoPermission : undefined} onClick={rules.manage === PermissionRule.Enabled ? () => run('manage') : undefined}>
|
|
86
|
+
<span className='foundation-menu-check' /><span>{strings.viewsManage}</span>
|
|
87
|
+
</button>
|
|
88
|
+
)}
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
)}
|
|
92
|
+
</FoundationFrame>
|
|
93
|
+
);
|
|
94
|
+
}
|