@aglyn/shared-ui-jsx 1.0.0-beta.270 → 1.0.0-beta.271
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/package.json +5 -5
- package/src/lib/components/option-card-grid.component.d.ts +40 -4
- package/src/lib/components/option-card-grid.component.js +167 -127
- package/src/lib/components/option-card-grid.component.js.map +1 -1
- package/src/lib/components/plan-locked-control.component.d.ts +29 -0
- package/src/lib/components/plan-locked-control.component.js +58 -0
- package/src/lib/components/plan-locked-control.component.js.map +1 -0
- package/src/lib/components/plan-upsell-notice.component.d.ts +30 -0
- package/src/lib/components/plan-upsell-notice.component.js +45 -0
- package/src/lib/components/plan-upsell-notice.component.js.map +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aglyn/shared-ui-jsx",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.271",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"homepage": "https://aglyn.com",
|
|
6
6
|
"repository": {
|
|
@@ -25,10 +25,10 @@
|
|
|
25
25
|
"./package.json": "./package.json"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@aglyn/shared-data-mdi": "1.0.0-beta.
|
|
29
|
-
"@aglyn/shared-ui-theme": "1.0.0-beta.
|
|
30
|
-
"@aglyn/shared-util-tools": "1.0.0-beta.
|
|
31
|
-
"@aglyn/shared-util-vendor": "1.0.0-beta.
|
|
28
|
+
"@aglyn/shared-data-mdi": "1.0.0-beta.271",
|
|
29
|
+
"@aglyn/shared-ui-theme": "1.0.0-beta.271",
|
|
30
|
+
"@aglyn/shared-util-tools": "1.0.0-beta.271",
|
|
31
|
+
"@aglyn/shared-util-vendor": "1.0.0-beta.271",
|
|
32
32
|
"@mdi/js": "^7.4.47",
|
|
33
33
|
"@swc/helpers": "0.5.23",
|
|
34
34
|
"change-case": "^5.4.4",
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* See the License for the specific language governing permissions and
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/
|
|
17
|
+
import { type Theme } from '@mui/material/styles';
|
|
17
18
|
/**
|
|
18
19
|
* A picture across the top of an {@link OptionCard} (AGL-3898): a real
|
|
19
20
|
* example of the choice, at a fixed size so the grid does not shift while it
|
|
@@ -52,12 +53,47 @@ export interface OptionCardGridProps<T extends string = string> {
|
|
|
52
53
|
/** `data-*` attributes for the grid's root, such as a page's own marker for where clicks land. */
|
|
53
54
|
[data: `data-${string}`]: string | undefined;
|
|
54
55
|
}
|
|
56
|
+
/**
|
|
57
|
+
* How an option card sits on the page around it (AGL-3985). A grid of them is
|
|
58
|
+
* often drawn on a dialog's gray, where a transparent card with a divider
|
|
59
|
+
* outline melted into the page, so each card is:
|
|
60
|
+
*
|
|
61
|
+
* - a surface of its own, `background.paper`;
|
|
62
|
+
* - outlined as strongly as an outlined field (the text color at 23%, the
|
|
63
|
+
* border MUI's OutlinedInput draws), the text color on hover;
|
|
64
|
+
* - once picked, outlined 2px in the primary color, from a 1px ring laid
|
|
65
|
+
* outside its 1px border so the grid never shifts, with a check in a disc
|
|
66
|
+
* at its top corner ({@link OptionCardCheck});
|
|
67
|
+
* - ringed in the primary color while it has the keyboard's focus.
|
|
68
|
+
*
|
|
69
|
+
* Theme values only, so it reads the same in light and dark mode.
|
|
70
|
+
*/
|
|
71
|
+
export declare function optionCardSx(selected: boolean): (theme: Theme) => {
|
|
72
|
+
position: "relative";
|
|
73
|
+
borderRadius: number;
|
|
74
|
+
bgcolor: string;
|
|
75
|
+
borderColor: string;
|
|
76
|
+
boxShadow: string;
|
|
77
|
+
transition: string;
|
|
78
|
+
'&:hover': {
|
|
79
|
+
borderColor: string;
|
|
80
|
+
};
|
|
81
|
+
'&:has(.Mui-focusVisible)': {
|
|
82
|
+
outline: string;
|
|
83
|
+
outlineOffset: string;
|
|
84
|
+
};
|
|
85
|
+
};
|
|
86
|
+
/** The check a picked card carries in its top corner: over its picture, or beside its words. */
|
|
87
|
+
export declare function OptionCardCheck(): import("react").JSX.Element;
|
|
55
88
|
/**
|
|
56
89
|
* A grid of cards to pick one from (AGL-3660): a radio group drawn as cards,
|
|
57
|
-
* each with an icon or a picture (AGL-3898), a title and a line under it.
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
90
|
+
* each with an icon or a picture (AGL-3898), a title and a line under it. Each
|
|
91
|
+
* card is a surface with an outline that reads on a gray page, its title in
|
|
92
|
+
* the text color and its line in the secondary text color, both at AA on the
|
|
93
|
+
* card (AGL-3985). The picked card is outlined 2px in the primary color and
|
|
94
|
+
* carries a check; the arrow keys move between cards and pick, as a radio
|
|
95
|
+
* group's do, only the picked card is in the tab order, and the focused card
|
|
96
|
+
* is ringed.
|
|
61
97
|
*/
|
|
62
98
|
export declare function OptionCardGrid<T extends string = string>({ label, options, value, onChange, columns, ...rest }: OptionCardGridProps<T>): import("react").JSX.Element;
|
|
63
99
|
export default OptionCardGrid;
|
|
@@ -21,12 +21,73 @@ import { Box, Card, CardActionArea, Stack, Typography } from "@mui/material";
|
|
|
21
21
|
import { alpha } from "@mui/material/styles";
|
|
22
22
|
import { useRef } from "react";
|
|
23
23
|
import { MdiIcon } from "./mdi-icon/mdi-icon.js";
|
|
24
|
+
/**
|
|
25
|
+
* How an option card sits on the page around it (AGL-3985). A grid of them is
|
|
26
|
+
* often drawn on a dialog's gray, where a transparent card with a divider
|
|
27
|
+
* outline melted into the page, so each card is:
|
|
28
|
+
*
|
|
29
|
+
* - a surface of its own, `background.paper`;
|
|
30
|
+
* - outlined as strongly as an outlined field (the text color at 23%, the
|
|
31
|
+
* border MUI's OutlinedInput draws), the text color on hover;
|
|
32
|
+
* - once picked, outlined 2px in the primary color, from a 1px ring laid
|
|
33
|
+
* outside its 1px border so the grid never shifts, with a check in a disc
|
|
34
|
+
* at its top corner ({@link OptionCardCheck});
|
|
35
|
+
* - ringed in the primary color while it has the keyboard's focus.
|
|
36
|
+
*
|
|
37
|
+
* Theme values only, so it reads the same in light and dark mode.
|
|
38
|
+
*/ export function optionCardSx(selected) {
|
|
39
|
+
return (theme)=>({
|
|
40
|
+
position: 'relative',
|
|
41
|
+
borderRadius: 2,
|
|
42
|
+
bgcolor: 'background.paper',
|
|
43
|
+
borderColor: selected ? theme.palette.primary.main : alpha(theme.palette.text.primary, 0.23),
|
|
44
|
+
boxShadow: selected ? `0 0 0 1px ${theme.palette.primary.main}` : 'none',
|
|
45
|
+
transition: theme.transitions.create([
|
|
46
|
+
'border-color',
|
|
47
|
+
'box-shadow'
|
|
48
|
+
]),
|
|
49
|
+
'&:hover': {
|
|
50
|
+
borderColor: selected ? theme.palette.primary.main : theme.palette.text.primary
|
|
51
|
+
},
|
|
52
|
+
'&:has(.Mui-focusVisible)': {
|
|
53
|
+
outline: `2px solid ${theme.palette.primary.main}`,
|
|
54
|
+
outlineOffset: '2px'
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
/** The check a picked card carries in its top corner: over its picture, or beside its words. */ export function OptionCardCheck() {
|
|
59
|
+
return /*#__PURE__*/ _jsx(Box, {
|
|
60
|
+
"aria-hidden": true,
|
|
61
|
+
"data-option-card-check": "",
|
|
62
|
+
sx: {
|
|
63
|
+
position: 'absolute',
|
|
64
|
+
top: 8,
|
|
65
|
+
right: 8,
|
|
66
|
+
zIndex: 1,
|
|
67
|
+
display: 'flex',
|
|
68
|
+
borderRadius: '50%',
|
|
69
|
+
bgcolor: 'background.paper',
|
|
70
|
+
color: 'primary.main',
|
|
71
|
+
boxShadow: 2,
|
|
72
|
+
pointerEvents: 'none'
|
|
73
|
+
},
|
|
74
|
+
children: /*#__PURE__*/ _jsx(MdiIcon, {
|
|
75
|
+
path: mdiCheckCircle.path,
|
|
76
|
+
sx: {
|
|
77
|
+
fontSize: 24
|
|
78
|
+
}
|
|
79
|
+
})
|
|
80
|
+
});
|
|
81
|
+
}
|
|
24
82
|
/**
|
|
25
83
|
* A grid of cards to pick one from (AGL-3660): a radio group drawn as cards,
|
|
26
|
-
* each with an icon or a picture (AGL-3898), a title and a line under it.
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
84
|
+
* each with an icon or a picture (AGL-3898), a title and a line under it. Each
|
|
85
|
+
* card is a surface with an outline that reads on a gray page, its title in
|
|
86
|
+
* the text color and its line in the secondary text color, both at AA on the
|
|
87
|
+
* card (AGL-3985). The picked card is outlined 2px in the primary color and
|
|
88
|
+
* carries a check; the arrow keys move between cards and pick, as a radio
|
|
89
|
+
* group's do, only the picked card is in the tab order, and the focused card
|
|
90
|
+
* is ringed.
|
|
30
91
|
*/ export function OptionCardGrid(_0) {
|
|
31
92
|
let { label, options, value, onChange, columns = {
|
|
32
93
|
xs: 1,
|
|
@@ -59,7 +120,7 @@ import { MdiIcon } from "./mdi-icon/mdi-icon.js";
|
|
|
59
120
|
}, rest, {
|
|
60
121
|
sx: {
|
|
61
122
|
display: 'grid',
|
|
62
|
-
gap:
|
|
123
|
+
gap: 2,
|
|
63
124
|
gridTemplateColumns: {
|
|
64
125
|
xs: template((_columns_xs = columns.xs) != null ? _columns_xs : 1),
|
|
65
126
|
sm: template(columns.sm),
|
|
@@ -68,132 +129,111 @@ import { MdiIcon } from "./mdi-icon/mdi-icon.js";
|
|
|
68
129
|
},
|
|
69
130
|
children: options.map((option, index)=>{
|
|
70
131
|
const selected = option.id === value;
|
|
71
|
-
return /*#__PURE__*/
|
|
132
|
+
return /*#__PURE__*/ _jsxs(Card, {
|
|
72
133
|
variant: "outlined",
|
|
73
|
-
sx: (
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
width:
|
|
109
|
-
height:
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
right: 8,
|
|
130
|
-
display: 'flex',
|
|
131
|
-
borderRadius: '50%',
|
|
132
|
-
bgcolor: 'background.paper',
|
|
133
|
-
color: 'primary.main',
|
|
134
|
-
boxShadow: 2
|
|
135
|
-
},
|
|
136
|
-
children: /*#__PURE__*/ _jsx(MdiIcon, {
|
|
137
|
-
path: mdiCheckCircle.path,
|
|
138
|
-
sx: {
|
|
139
|
-
fontSize: 24
|
|
140
|
-
}
|
|
141
|
-
})
|
|
142
|
-
})
|
|
143
|
-
]
|
|
144
|
-
}),
|
|
145
|
-
/*#__PURE__*/ _jsxs(Stack, {
|
|
146
|
-
direction: "row",
|
|
147
|
-
spacing: 1.5,
|
|
148
|
-
sx: {
|
|
149
|
-
alignItems: 'flex-start',
|
|
150
|
-
width: '100%',
|
|
151
|
-
p: option.image ? 1.5 : 0
|
|
152
|
-
},
|
|
153
|
-
children: [
|
|
154
|
-
option.icon && /*#__PURE__*/ _jsx(Box, {
|
|
155
|
-
sx: {
|
|
156
|
-
color: selected ? 'primary.main' : 'text.secondary',
|
|
157
|
-
display: 'flex',
|
|
158
|
-
pt: 0.25
|
|
159
|
-
},
|
|
160
|
-
"aria-hidden": true,
|
|
161
|
-
children: /*#__PURE__*/ _jsx(MdiIcon, {
|
|
162
|
-
path: option.icon,
|
|
134
|
+
sx: optionCardSx(selected),
|
|
135
|
+
children: [
|
|
136
|
+
selected && /*#__PURE__*/ _jsx(OptionCardCheck, {}),
|
|
137
|
+
/*#__PURE__*/ _jsxs(CardActionArea, {
|
|
138
|
+
ref: (node)=>{
|
|
139
|
+
refs.current[index] = node;
|
|
140
|
+
},
|
|
141
|
+
role: "radio",
|
|
142
|
+
"aria-checked": selected,
|
|
143
|
+
"aria-label": option.description ? `${option.title}: ${option.description}` : option.title,
|
|
144
|
+
tabIndex: index === focusable ? 0 : -1,
|
|
145
|
+
onClick: ()=>onChange(option.id),
|
|
146
|
+
onKeyDown: (event)=>onKeyDown(event, index),
|
|
147
|
+
sx: {
|
|
148
|
+
p: option.image ? 0 : 2,
|
|
149
|
+
// Room for the check beside a card's words, where no picture carries it.
|
|
150
|
+
pr: option.image ? 0 : 5,
|
|
151
|
+
height: '100%',
|
|
152
|
+
minHeight: 44,
|
|
153
|
+
display: 'flex',
|
|
154
|
+
flexDirection: option.image ? 'column' : 'row',
|
|
155
|
+
alignItems: option.image ? 'stretch' : 'flex-start',
|
|
156
|
+
justifyContent: 'flex-start'
|
|
157
|
+
},
|
|
158
|
+
children: [
|
|
159
|
+
option.image && /*#__PURE__*/ _jsx(Box, {
|
|
160
|
+
component: "img",
|
|
161
|
+
src: option.image.src,
|
|
162
|
+
alt: option.image.alt,
|
|
163
|
+
width: option.image.width,
|
|
164
|
+
height: option.image.height,
|
|
165
|
+
loading: "lazy",
|
|
166
|
+
decoding: "async",
|
|
167
|
+
sx: {
|
|
168
|
+
display: 'block',
|
|
169
|
+
width: '100%',
|
|
170
|
+
height: 'auto',
|
|
171
|
+
aspectRatio: `${option.image.width} / ${option.image.height}`,
|
|
172
|
+
objectFit: 'cover',
|
|
173
|
+
objectPosition: 'top',
|
|
174
|
+
borderBottom: 1,
|
|
175
|
+
borderColor: 'divider',
|
|
176
|
+
bgcolor: 'action.hover'
|
|
177
|
+
}
|
|
178
|
+
}),
|
|
179
|
+
/*#__PURE__*/ _jsxs(Stack, {
|
|
180
|
+
direction: "row",
|
|
181
|
+
spacing: 1.5,
|
|
182
|
+
sx: {
|
|
183
|
+
alignItems: 'flex-start',
|
|
184
|
+
width: '100%',
|
|
185
|
+
px: option.image ? 2 : 0,
|
|
186
|
+
py: option.image ? 1.5 : 0
|
|
187
|
+
},
|
|
188
|
+
children: [
|
|
189
|
+
option.icon && /*#__PURE__*/ _jsx(Box, {
|
|
163
190
|
sx: {
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
minWidth: 0
|
|
172
|
-
},
|
|
173
|
-
children: [
|
|
174
|
-
/*#__PURE__*/ _jsx(Typography, {
|
|
175
|
-
variant: "subtitle2",
|
|
176
|
-
component: "span",
|
|
177
|
-
sx: {
|
|
178
|
-
display: 'block'
|
|
179
|
-
},
|
|
180
|
-
children: option.title
|
|
181
|
-
}),
|
|
182
|
-
option.description && /*#__PURE__*/ _jsx(Typography, {
|
|
183
|
-
variant: "body2",
|
|
184
|
-
color: "text.secondary",
|
|
185
|
-
component: "span",
|
|
191
|
+
color: selected ? 'primary.main' : 'text.secondary',
|
|
192
|
+
display: 'flex',
|
|
193
|
+
pt: 0.25
|
|
194
|
+
},
|
|
195
|
+
"aria-hidden": true,
|
|
196
|
+
children: /*#__PURE__*/ _jsx(MdiIcon, {
|
|
197
|
+
path: option.icon,
|
|
186
198
|
sx: {
|
|
187
|
-
|
|
188
|
-
}
|
|
189
|
-
children: option.description
|
|
199
|
+
fontSize: 22
|
|
200
|
+
}
|
|
190
201
|
})
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
202
|
+
}),
|
|
203
|
+
/*#__PURE__*/ _jsxs(Box, {
|
|
204
|
+
sx: {
|
|
205
|
+
flex: 1,
|
|
206
|
+
minWidth: 0
|
|
207
|
+
},
|
|
208
|
+
children: [
|
|
209
|
+
/*#__PURE__*/ _jsx(Typography, {
|
|
210
|
+
variant: "subtitle2",
|
|
211
|
+
component: "span",
|
|
212
|
+
sx: {
|
|
213
|
+
display: 'block',
|
|
214
|
+
color: 'text.primary',
|
|
215
|
+
fontWeight: 600,
|
|
216
|
+
lineHeight: 1.4
|
|
217
|
+
},
|
|
218
|
+
children: option.title
|
|
219
|
+
}),
|
|
220
|
+
option.description && /*#__PURE__*/ _jsx(Typography, {
|
|
221
|
+
variant: "body2",
|
|
222
|
+
component: "span",
|
|
223
|
+
sx: {
|
|
224
|
+
display: 'block',
|
|
225
|
+
color: 'text.secondary',
|
|
226
|
+
mt: 0.25
|
|
227
|
+
},
|
|
228
|
+
children: option.description
|
|
229
|
+
})
|
|
230
|
+
]
|
|
231
|
+
})
|
|
232
|
+
]
|
|
233
|
+
})
|
|
234
|
+
]
|
|
235
|
+
})
|
|
236
|
+
]
|
|
197
237
|
}, option.id);
|
|
198
238
|
})
|
|
199
239
|
}));
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/shared/ui/jsx/src/lib/components/option-card-grid.component.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { mdiCheckCircle } from '@aglyn/shared-data-mdi'\nimport { Box, Card, CardActionArea, Stack, Typography } from '@mui/material'\nimport { alpha, type Theme } from '@mui/material/styles'\nimport { useRef, type KeyboardEvent } from 'react'\nimport { MdiIcon } from './mdi-icon/mdi-icon'\n\n/**\n * A picture across the top of an {@link OptionCard} (AGL-3898): a real\n * example of the choice, at a fixed size so the grid does not shift while it\n * loads, and loaded lazily, since a grid of them is mostly below the fold.\n */\nexport interface OptionCardImage {\n src: string\n /** What the picture shows, for a reader who cannot see it. */\n alt: string\n width: number\n height: number\n}\n\n/** One choice in an {@link OptionCardGrid}. */\nexport interface OptionCard<T extends string = string> {\n id: T\n title: string\n description?: string\n /** An SVG path, such as an MDI icon's. */\n icon?: string\n /** A picture of the choice, drawn above its title. */\n image?: OptionCardImage\n}\n\nexport interface OptionCardGridProps<T extends string = string> {\n /** What the group is, for assistive technology. */\n label: string\n options: readonly OptionCard<T>[]\n /** The selected option, or `null` for none. */\n value: T | null\n onChange: (value: T) => void\n /** Columns at each breakpoint. */\n columns?: { xs?: number; sm?: number; md?: number }\n /** `data-*` attributes for the grid's root, such as a page's own marker for where clicks land. */\n [data: `data-${string}`]: string | undefined\n}\n\n/**\n * A grid of cards to pick one from (AGL-3660): a radio group drawn as cards,\n * each with an icon or a picture (AGL-3898), a title and a line under it. The\n * selected card carries the primary color, and a picture card a check over\n * its picture; the arrow keys move between cards and pick, as a radio group's\n * do, and only the picked card is in the tab order.\n */\nexport function OptionCardGrid<T extends string = string>({\n label,\n options,\n value,\n onChange,\n columns = { xs: 1, sm: 2, md: 3 },\n ...rest\n}: OptionCardGridProps<T>) {\n const refs = useRef<Array<HTMLButtonElement | null>>([])\n const selectedIndex = options.findIndex((option) => option.id === value)\n const focusable = selectedIndex === -1 ? 0 : selectedIndex\n\n const onKeyDown = (event: KeyboardEvent, index: number) => {\n const step =\n event.key === 'ArrowRight' || event.key === 'ArrowDown'\n ? 1\n : event.key === 'ArrowLeft' || event.key === 'ArrowUp'\n ? -1\n : 0\n if (!step) return\n event.preventDefault()\n const next = (index + step + options.length) % options.length\n refs.current[next]?.focus()\n onChange(options[next].id)\n }\n\n const template = (count?: number) => (count ? `repeat(${count}, minmax(0, 1fr))` : undefined)\n return (\n <Box\n role=\"radiogroup\"\n aria-label={label}\n {...rest}\n sx={{\n display: 'grid',\n gap: 1.5,\n gridTemplateColumns: {\n xs: template(columns.xs ?? 1),\n sm: template(columns.sm),\n md: template(columns.md),\n },\n }}\n >\n {options.map((option, index) => {\n const selected = option.id === value\n return (\n <Card\n key={option.id}\n variant=\"outlined\"\n sx={(theme: Theme) => ({\n borderRadius: 2,\n borderColor: selected ? 'primary.main' : 'divider',\n bgcolor: selected ? alpha(theme.palette.primary.main, 0.08) : 'transparent',\n })}\n >\n <CardActionArea\n ref={(node: HTMLButtonElement | null) => {\n refs.current[index] = node\n }}\n role=\"radio\"\n aria-checked={selected}\n aria-label={option.description ? `${option.title}: ${option.description}` : option.title}\n tabIndex={index === focusable ? 0 : -1}\n onClick={() => onChange(option.id)}\n onKeyDown={(event) => onKeyDown(event, index)}\n sx={{\n p: option.image ? 0 : 2,\n height: '100%',\n minHeight: 44,\n display: 'flex',\n flexDirection: option.image ? 'column' : 'row',\n alignItems: option.image ? 'stretch' : 'flex-start',\n justifyContent: 'flex-start',\n }}\n >\n {option.image && (\n <Box sx={{ position: 'relative', width: '100%' }}>\n <Box\n component=\"img\"\n src={option.image.src}\n alt={option.image.alt}\n width={option.image.width}\n height={option.image.height}\n loading=\"lazy\"\n decoding=\"async\"\n sx={{\n display: 'block',\n width: '100%',\n height: 'auto',\n aspectRatio: `${option.image.width} / ${option.image.height}`,\n objectFit: 'cover',\n objectPosition: 'top',\n borderBottom: 1,\n borderColor: 'divider',\n bgcolor: 'action.hover',\n }}\n />\n {selected && (\n <Box\n aria-hidden\n sx={{\n position: 'absolute',\n top: 8,\n right: 8,\n display: 'flex',\n borderRadius: '50%',\n bgcolor: 'background.paper',\n color: 'primary.main',\n boxShadow: 2,\n }}\n >\n <MdiIcon path={mdiCheckCircle.path} sx={{ fontSize: 24 }} />\n </Box>\n )}\n </Box>\n )}\n <Stack direction=\"row\" spacing={1.5} sx={{ alignItems: 'flex-start', width: '100%', p: option.image ? 1.5 : 0 }}>\n {option.icon && (\n <Box sx={{ color: selected ? 'primary.main' : 'text.secondary', display: 'flex', pt: 0.25 }} aria-hidden>\n <MdiIcon path={option.icon} sx={{ fontSize: 22 }} />\n </Box>\n )}\n <Box sx={{ flex: 1, minWidth: 0 }}>\n <Typography variant=\"subtitle2\" component=\"span\" sx={{ display: 'block' }}>\n {option.title}\n </Typography>\n {option.description && (\n <Typography variant=\"body2\" color=\"text.secondary\" component=\"span\" sx={{ display: 'block' }}>\n {option.description}\n </Typography>\n )}\n </Box>\n </Stack>\n </CardActionArea>\n </Card>\n )\n })}\n </Box>\n )\n}\n\nexport default OptionCardGrid\n"],"names":["mdiCheckCircle","Box","Card","CardActionArea","Stack","Typography","alpha","useRef","MdiIcon","OptionCardGrid","label","options","value","onChange","columns","xs","sm","md","rest","refs","selectedIndex","findIndex","option","id","focusable","onKeyDown","event","index","step","key","preventDefault","next","length","current","focus","template","count","undefined","role","aria-label","sx","display","gap","gridTemplateColumns","map","selected","variant","theme","borderRadius","borderColor","bgcolor","palette","primary","main","ref","node","aria-checked","description","title","tabIndex","onClick","p","image","height","minHeight","flexDirection","alignItems","justifyContent","position","width","component","src","alt","loading","decoding","aspectRatio","objectFit","objectPosition","borderBottom","aria-hidden","top","right","color","boxShadow","path","fontSize","direction","spacing","icon","pt","flex","minWidth"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/shared/ui/jsx/src/lib/components/option-card-grid.component.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { mdiCheckCircle } from '@aglyn/shared-data-mdi'\nimport { Box, Card, CardActionArea, Stack, Typography } from '@mui/material'\nimport { alpha, type Theme } from '@mui/material/styles'\nimport { useRef, type KeyboardEvent } from 'react'\nimport { MdiIcon } from './mdi-icon/mdi-icon'\n\n/**\n * A picture across the top of an {@link OptionCard} (AGL-3898): a real\n * example of the choice, at a fixed size so the grid does not shift while it\n * loads, and loaded lazily, since a grid of them is mostly below the fold.\n */\nexport interface OptionCardImage {\n src: string\n /** What the picture shows, for a reader who cannot see it. */\n alt: string\n width: number\n height: number\n}\n\n/** One choice in an {@link OptionCardGrid}. */\nexport interface OptionCard<T extends string = string> {\n id: T\n title: string\n description?: string\n /** An SVG path, such as an MDI icon's. */\n icon?: string\n /** A picture of the choice, drawn above its title. */\n image?: OptionCardImage\n}\n\nexport interface OptionCardGridProps<T extends string = string> {\n /** What the group is, for assistive technology. */\n label: string\n options: readonly OptionCard<T>[]\n /** The selected option, or `null` for none. */\n value: T | null\n onChange: (value: T) => void\n /** Columns at each breakpoint. */\n columns?: { xs?: number; sm?: number; md?: number }\n /** `data-*` attributes for the grid's root, such as a page's own marker for where clicks land. */\n [data: `data-${string}`]: string | undefined\n}\n\n/**\n * How an option card sits on the page around it (AGL-3985). A grid of them is\n * often drawn on a dialog's gray, where a transparent card with a divider\n * outline melted into the page, so each card is:\n *\n * - a surface of its own, `background.paper`;\n * - outlined as strongly as an outlined field (the text color at 23%, the\n * border MUI's OutlinedInput draws), the text color on hover;\n * - once picked, outlined 2px in the primary color, from a 1px ring laid\n * outside its 1px border so the grid never shifts, with a check in a disc\n * at its top corner ({@link OptionCardCheck});\n * - ringed in the primary color while it has the keyboard's focus.\n *\n * Theme values only, so it reads the same in light and dark mode.\n */\nexport function optionCardSx(selected: boolean) {\n return (theme: Theme) => ({\n position: 'relative' as const,\n borderRadius: 2,\n bgcolor: 'background.paper',\n borderColor: selected ? theme.palette.primary.main : alpha(theme.palette.text.primary, 0.23),\n boxShadow: selected ? `0 0 0 1px ${theme.palette.primary.main}` : 'none',\n transition: theme.transitions.create(['border-color', 'box-shadow']),\n '&:hover': { borderColor: selected ? theme.palette.primary.main : theme.palette.text.primary },\n '&:has(.Mui-focusVisible)': {\n outline: `2px solid ${theme.palette.primary.main}`,\n outlineOffset: '2px',\n },\n })\n}\n\n/** The check a picked card carries in its top corner: over its picture, or beside its words. */\nexport function OptionCardCheck() {\n return (\n <Box\n aria-hidden\n data-option-card-check=\"\"\n sx={{\n position: 'absolute',\n top: 8,\n right: 8,\n zIndex: 1,\n display: 'flex',\n borderRadius: '50%',\n bgcolor: 'background.paper',\n color: 'primary.main',\n boxShadow: 2,\n pointerEvents: 'none',\n }}\n >\n <MdiIcon path={mdiCheckCircle.path} sx={{ fontSize: 24 }} />\n </Box>\n )\n}\n\n/**\n * A grid of cards to pick one from (AGL-3660): a radio group drawn as cards,\n * each with an icon or a picture (AGL-3898), a title and a line under it. Each\n * card is a surface with an outline that reads on a gray page, its title in\n * the text color and its line in the secondary text color, both at AA on the\n * card (AGL-3985). The picked card is outlined 2px in the primary color and\n * carries a check; the arrow keys move between cards and pick, as a radio\n * group's do, only the picked card is in the tab order, and the focused card\n * is ringed.\n */\nexport function OptionCardGrid<T extends string = string>({\n label,\n options,\n value,\n onChange,\n columns = { xs: 1, sm: 2, md: 3 },\n ...rest\n}: OptionCardGridProps<T>) {\n const refs = useRef<Array<HTMLButtonElement | null>>([])\n const selectedIndex = options.findIndex((option) => option.id === value)\n const focusable = selectedIndex === -1 ? 0 : selectedIndex\n\n const onKeyDown = (event: KeyboardEvent, index: number) => {\n const step =\n event.key === 'ArrowRight' || event.key === 'ArrowDown'\n ? 1\n : event.key === 'ArrowLeft' || event.key === 'ArrowUp'\n ? -1\n : 0\n if (!step) return\n event.preventDefault()\n const next = (index + step + options.length) % options.length\n refs.current[next]?.focus()\n onChange(options[next].id)\n }\n\n const template = (count?: number) => (count ? `repeat(${count}, minmax(0, 1fr))` : undefined)\n return (\n <Box\n role=\"radiogroup\"\n aria-label={label}\n {...rest}\n sx={{\n display: 'grid',\n gap: 2,\n gridTemplateColumns: {\n xs: template(columns.xs ?? 1),\n sm: template(columns.sm),\n md: template(columns.md),\n },\n }}\n >\n {options.map((option, index) => {\n const selected = option.id === value\n return (\n <Card key={option.id} variant=\"outlined\" sx={optionCardSx(selected)}>\n {selected && <OptionCardCheck />}\n <CardActionArea\n ref={(node: HTMLButtonElement | null) => {\n refs.current[index] = node\n }}\n role=\"radio\"\n aria-checked={selected}\n aria-label={option.description ? `${option.title}: ${option.description}` : option.title}\n tabIndex={index === focusable ? 0 : -1}\n onClick={() => onChange(option.id)}\n onKeyDown={(event) => onKeyDown(event, index)}\n sx={{\n p: option.image ? 0 : 2,\n // Room for the check beside a card's words, where no picture carries it.\n pr: option.image ? 0 : 5,\n height: '100%',\n minHeight: 44,\n display: 'flex',\n flexDirection: option.image ? 'column' : 'row',\n alignItems: option.image ? 'stretch' : 'flex-start',\n justifyContent: 'flex-start',\n }}\n >\n {option.image && (\n <Box\n component=\"img\"\n src={option.image.src}\n alt={option.image.alt}\n width={option.image.width}\n height={option.image.height}\n loading=\"lazy\"\n decoding=\"async\"\n sx={{\n display: 'block',\n width: '100%',\n height: 'auto',\n aspectRatio: `${option.image.width} / ${option.image.height}`,\n objectFit: 'cover',\n objectPosition: 'top',\n borderBottom: 1,\n borderColor: 'divider',\n bgcolor: 'action.hover',\n }}\n />\n )}\n <Stack\n direction=\"row\"\n spacing={1.5}\n sx={{ alignItems: 'flex-start', width: '100%', px: option.image ? 2 : 0, py: option.image ? 1.5 : 0 }}\n >\n {option.icon && (\n <Box sx={{ color: selected ? 'primary.main' : 'text.secondary', display: 'flex', pt: 0.25 }} aria-hidden>\n <MdiIcon path={option.icon} sx={{ fontSize: 22 }} />\n </Box>\n )}\n <Box sx={{ flex: 1, minWidth: 0 }}>\n <Typography\n variant=\"subtitle2\"\n component=\"span\"\n sx={{ display: 'block', color: 'text.primary', fontWeight: 600, lineHeight: 1.4 }}\n >\n {option.title}\n </Typography>\n {option.description && (\n <Typography variant=\"body2\" component=\"span\" sx={{ display: 'block', color: 'text.secondary', mt: 0.25 }}>\n {option.description}\n </Typography>\n )}\n </Box>\n </Stack>\n </CardActionArea>\n </Card>\n )\n })}\n </Box>\n )\n}\n\nexport default OptionCardGrid\n"],"names":["mdiCheckCircle","Box","Card","CardActionArea","Stack","Typography","alpha","useRef","MdiIcon","optionCardSx","selected","theme","position","borderRadius","bgcolor","borderColor","palette","primary","main","text","boxShadow","transition","transitions","create","outline","outlineOffset","OptionCardCheck","aria-hidden","data-option-card-check","sx","top","right","zIndex","display","color","pointerEvents","path","fontSize","OptionCardGrid","label","options","value","onChange","columns","xs","sm","md","rest","refs","selectedIndex","findIndex","option","id","focusable","onKeyDown","event","index","step","key","preventDefault","next","length","current","focus","template","count","undefined","role","aria-label","gap","gridTemplateColumns","map","variant","ref","node","aria-checked","description","title","tabIndex","onClick","p","image","pr","height","minHeight","flexDirection","alignItems","justifyContent","component","src","alt","width","loading","decoding","aspectRatio","objectFit","objectPosition","borderBottom","direction","spacing","px","py","icon","pt","flex","minWidth","fontWeight","lineHeight","mt"],"mappings":"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"}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { ReactElement } from 'react';
|
|
2
|
+
export interface PlanLockedControlProps {
|
|
3
|
+
/** The plan has settled and does not include the feature. */
|
|
4
|
+
locked: boolean;
|
|
5
|
+
/** Why, in one sentence: "Writing with AI is included from Starter." */
|
|
6
|
+
reason: string;
|
|
7
|
+
/** The cheapest plan that includes it ("Starter"), when one does. */
|
|
8
|
+
planLabel: string | undefined;
|
|
9
|
+
/** The plan grid on the Billing page. */
|
|
10
|
+
upgradeHref: string | undefined;
|
|
11
|
+
/** A single control that accepts a `disabled` prop. */
|
|
12
|
+
children: ReactElement<{
|
|
13
|
+
disabled?: boolean;
|
|
14
|
+
}>;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* A plan-gated control for a place with no room for a notice — a toolbar, a
|
|
18
|
+
* table row, a card header: the control disabled with the reason, and a small
|
|
19
|
+
* "Included from Starter" link beside it to the plan grid.
|
|
20
|
+
*
|
|
21
|
+
* The link is the half `BlockedControl` cannot carry. A tooltip on a disabled
|
|
22
|
+
* button can name the plan but cannot hold a link anybody can press, and a
|
|
23
|
+
* reason with nowhere to act on it is the dead end this replaces.
|
|
24
|
+
*/
|
|
25
|
+
export declare function PlanLockedControl(props: PlanLockedControlProps): import("react").JSX.Element;
|
|
26
|
+
export declare namespace PlanLockedControl {
|
|
27
|
+
var displayName: string;
|
|
28
|
+
}
|
|
29
|
+
export default PlanLockedControl;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/ 'use client';
|
|
17
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
18
|
+
import { Box } from "@mui/material";
|
|
19
|
+
import { AppLink } from "./app-link.js";
|
|
20
|
+
import { BlockedControl } from "./blocked-control.component.js";
|
|
21
|
+
/**
|
|
22
|
+
* A plan-gated control for a place with no room for a notice — a toolbar, a
|
|
23
|
+
* table row, a card header: the control disabled with the reason, and a small
|
|
24
|
+
* "Included from Starter" link beside it to the plan grid.
|
|
25
|
+
*
|
|
26
|
+
* The link is the half `BlockedControl` cannot carry. A tooltip on a disabled
|
|
27
|
+
* button can name the plan but cannot hold a link anybody can press, and a
|
|
28
|
+
* reason with nowhere to act on it is the dead end this replaces.
|
|
29
|
+
*/ export function PlanLockedControl(props) {
|
|
30
|
+
const { locked, reason, planLabel, upgradeHref, children } = props;
|
|
31
|
+
if (!locked) return children;
|
|
32
|
+
return /*#__PURE__*/ _jsxs(Box, {
|
|
33
|
+
component: "span",
|
|
34
|
+
sx: {
|
|
35
|
+
display: 'inline-flex',
|
|
36
|
+
alignItems: 'center',
|
|
37
|
+
gap: 1,
|
|
38
|
+
flexWrap: 'wrap'
|
|
39
|
+
},
|
|
40
|
+
children: [
|
|
41
|
+
/*#__PURE__*/ _jsx(BlockedControl, {
|
|
42
|
+
blocked: true,
|
|
43
|
+
reason: reason,
|
|
44
|
+
children: children
|
|
45
|
+
}),
|
|
46
|
+
upgradeHref ? /*#__PURE__*/ _jsx(AppLink, {
|
|
47
|
+
href: upgradeHref,
|
|
48
|
+
variant: "caption",
|
|
49
|
+
"aria-label": `${reason} Upgrade`,
|
|
50
|
+
children: planLabel ? `Included from ${planLabel}` : 'Upgrade'
|
|
51
|
+
}) : null
|
|
52
|
+
]
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
PlanLockedControl.displayName = 'PlanLockedControl';
|
|
56
|
+
export default PlanLockedControl;
|
|
57
|
+
|
|
58
|
+
//# sourceMappingURL=plan-locked-control.component.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/shared/ui/jsx/src/lib/components/plan-locked-control.component.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport { Box } from '@mui/material'\nimport type { ReactElement } from 'react'\nimport { AppLink } from './app-link'\nimport { BlockedControl } from './blocked-control.component'\n\nexport interface PlanLockedControlProps {\n /** The plan has settled and does not include the feature. */\n locked: boolean\n /** Why, in one sentence: \"Writing with AI is included from Starter.\" */\n reason: string\n /** The cheapest plan that includes it (\"Starter\"), when one does. */\n planLabel: string | undefined\n /** The plan grid on the Billing page. */\n upgradeHref: string | undefined\n /** A single control that accepts a `disabled` prop. */\n children: ReactElement<{ disabled?: boolean }>\n}\n\n/**\n * A plan-gated control for a place with no room for a notice — a toolbar, a\n * table row, a card header: the control disabled with the reason, and a small\n * \"Included from Starter\" link beside it to the plan grid.\n *\n * The link is the half `BlockedControl` cannot carry. A tooltip on a disabled\n * button can name the plan but cannot hold a link anybody can press, and a\n * reason with nowhere to act on it is the dead end this replaces.\n */\nexport function PlanLockedControl(props: PlanLockedControlProps) {\n const { locked, reason, planLabel, upgradeHref, children } = props\n if (!locked) return children\n return (\n <Box\n component=\"span\"\n sx={{ display: 'inline-flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}\n >\n <BlockedControl blocked reason={reason}>\n {children}\n </BlockedControl>\n {upgradeHref ? (\n <AppLink href={upgradeHref} variant=\"caption\" aria-label={`${reason} Upgrade`}>\n {planLabel ? `Included from ${planLabel}` : 'Upgrade'}\n </AppLink>\n ) : null}\n </Box>\n )\n}\n\nPlanLockedControl.displayName = 'PlanLockedControl'\n\nexport default PlanLockedControl\n"],"names":["Box","AppLink","BlockedControl","PlanLockedControl","props","locked","reason","planLabel","upgradeHref","children","component","sx","display","alignItems","gap","flexWrap","blocked","href","variant","aria-label","displayName"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;;AAEA,SAASA,GAAG,QAAQ,gBAAe;AAEnC,SAASC,OAAO,QAAQ,gBAAY;AACpC,SAASC,cAAc,QAAQ,iCAA6B;AAe5D;;;;;;;;CAQC,GACD,OAAO,SAASC,kBAAkBC,KAA6B;IAC7D,MAAM,EAAEC,MAAM,EAAEC,MAAM,EAAEC,SAAS,EAAEC,WAAW,EAAEC,QAAQ,EAAE,GAAGL;IAC7D,IAAI,CAACC,QAAQ,OAAOI;IACpB,qBACE,MAACT;QACCU,WAAU;QACVC,IAAI;YAAEC,SAAS;YAAeC,YAAY;YAAUC,KAAK;YAAGC,UAAU;QAAO;;0BAE7E,KAACb;gBAAec,OAAO;gBAACV,QAAQA;0BAC7BG;;YAEFD,4BACC,KAACP;gBAAQgB,MAAMT;gBAAaU,SAAQ;gBAAUC,cAAY,GAAGb,OAAO,QAAQ,CAAC;0BAC1EC,YAAY,CAAC,cAAc,EAAEA,WAAW,GAAG;iBAE5C;;;AAGV;AAEAJ,kBAAkBiB,WAAW,GAAG;AAEhC,eAAejB,kBAAiB"}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { AlertProps } from '@mui/material';
|
|
2
|
+
import type { ReactNode } from 'react';
|
|
3
|
+
export interface PlanUpsellNoticeProps {
|
|
4
|
+
/**
|
|
5
|
+
* What the feature does, in the operator's terms, so the notice sells
|
|
6
|
+
* something: "Schedule a version to go live at a set time."
|
|
7
|
+
*/
|
|
8
|
+
children: ReactNode;
|
|
9
|
+
/** The cheapest plan that includes it ("Business"), when one does. */
|
|
10
|
+
planLabel: string | undefined;
|
|
11
|
+
/** The plan grid on the Billing page. */
|
|
12
|
+
upgradeHref: string | undefined;
|
|
13
|
+
/** Extra layout on the alert (margins in a stack, mostly). */
|
|
14
|
+
sx?: AlertProps['sx'];
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* The locked state of a plan feature: what it does, which plan includes it,
|
|
18
|
+
* and one click to the plan grid.
|
|
19
|
+
*
|
|
20
|
+
* The plan name is not decoration. "This is a paid feature" is a dead end;
|
|
21
|
+
* "Included from Business" beside an Upgrade button is the path. It is a
|
|
22
|
+
* {@link PrerequisiteNotice} — a plan is one more prerequisite a workspace
|
|
23
|
+
* can fix itself — so it looks and reads like every other one.
|
|
24
|
+
*
|
|
25
|
+
* Pair it with `BlockedControl` (or `PlanLockedControl` where there is no
|
|
26
|
+
* room for a notice) so the control stays on screen, disabled, with the
|
|
27
|
+
* same reason on hover and for a screen reader.
|
|
28
|
+
*/
|
|
29
|
+
export declare function PlanUpsellNotice(props: PlanUpsellNoticeProps): import("react").JSX.Element;
|
|
30
|
+
export default PlanUpsellNotice;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/ 'use client';
|
|
17
|
+
import { jsxs as _jsxs } from "react/jsx-runtime";
|
|
18
|
+
import { PrerequisiteNotice } from "./prerequisite-notice.component.js";
|
|
19
|
+
/**
|
|
20
|
+
* The locked state of a plan feature: what it does, which plan includes it,
|
|
21
|
+
* and one click to the plan grid.
|
|
22
|
+
*
|
|
23
|
+
* The plan name is not decoration. "This is a paid feature" is a dead end;
|
|
24
|
+
* "Included from Business" beside an Upgrade button is the path. It is a
|
|
25
|
+
* {@link PrerequisiteNotice} — a plan is one more prerequisite a workspace
|
|
26
|
+
* can fix itself — so it looks and reads like every other one.
|
|
27
|
+
*
|
|
28
|
+
* Pair it with `BlockedControl` (or `PlanLockedControl` where there is no
|
|
29
|
+
* room for a notice) so the control stays on screen, disabled, with the
|
|
30
|
+
* same reason on hover and for a screen reader.
|
|
31
|
+
*/ export function PlanUpsellNotice(props) {
|
|
32
|
+
const { children, planLabel, upgradeHref, sx } = props;
|
|
33
|
+
return /*#__PURE__*/ _jsxs(PrerequisiteNotice, {
|
|
34
|
+
href: upgradeHref,
|
|
35
|
+
actionLabel: "Upgrade",
|
|
36
|
+
sx: sx,
|
|
37
|
+
children: [
|
|
38
|
+
children,
|
|
39
|
+
planLabel ? ` Included from ${planLabel}.` : null
|
|
40
|
+
]
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
export default PlanUpsellNotice;
|
|
44
|
+
|
|
45
|
+
//# sourceMappingURL=plan-upsell-notice.component.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/shared/ui/jsx/src/lib/components/plan-upsell-notice.component.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport type { AlertProps } from '@mui/material'\nimport type { ReactNode } from 'react'\nimport { PrerequisiteNotice } from './prerequisite-notice.component'\n\nexport interface PlanUpsellNoticeProps {\n /**\n * What the feature does, in the operator's terms, so the notice sells\n * something: \"Schedule a version to go live at a set time.\"\n */\n children: ReactNode\n /** The cheapest plan that includes it (\"Business\"), when one does. */\n planLabel: string | undefined\n /** The plan grid on the Billing page. */\n upgradeHref: string | undefined\n /** Extra layout on the alert (margins in a stack, mostly). */\n sx?: AlertProps['sx']\n}\n\n/**\n * The locked state of a plan feature: what it does, which plan includes it,\n * and one click to the plan grid.\n *\n * The plan name is not decoration. \"This is a paid feature\" is a dead end;\n * \"Included from Business\" beside an Upgrade button is the path. It is a\n * {@link PrerequisiteNotice} — a plan is one more prerequisite a workspace\n * can fix itself — so it looks and reads like every other one.\n *\n * Pair it with `BlockedControl` (or `PlanLockedControl` where there is no\n * room for a notice) so the control stays on screen, disabled, with the\n * same reason on hover and for a screen reader.\n */\nexport function PlanUpsellNotice(props: PlanUpsellNoticeProps) {\n const { children, planLabel, upgradeHref, sx } = props\n return (\n <PrerequisiteNotice href={upgradeHref} actionLabel=\"Upgrade\" sx={sx}>\n {children}\n {planLabel ? ` Included from ${planLabel}.` : null}\n </PrerequisiteNotice>\n )\n}\n\nexport default PlanUpsellNotice\n"],"names":["PrerequisiteNotice","PlanUpsellNotice","props","children","planLabel","upgradeHref","sx","href","actionLabel"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;;AAIA,SAASA,kBAAkB,QAAQ,qCAAiC;AAgBpE;;;;;;;;;;;;CAYC,GACD,OAAO,SAASC,iBAAiBC,KAA4B;IAC3D,MAAM,EAAEC,QAAQ,EAAEC,SAAS,EAAEC,WAAW,EAAEC,EAAE,EAAE,GAAGJ;IACjD,qBACE,MAACF;QAAmBO,MAAMF;QAAaG,aAAY;QAAUF,IAAIA;;YAC9DH;YACAC,YAAY,CAAC,eAAe,EAAEA,UAAU,CAAC,CAAC,GAAG;;;AAGpD;AAEA,eAAeH,iBAAgB"}
|