@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aglyn/shared-ui-jsx",
3
- "version": "1.0.0-beta.270",
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.270",
29
- "@aglyn/shared-ui-theme": "1.0.0-beta.270",
30
- "@aglyn/shared-util-tools": "1.0.0-beta.270",
31
- "@aglyn/shared-util-vendor": "1.0.0-beta.270",
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. The
58
- * selected card carries the primary color, and a picture card a check over
59
- * its picture; the arrow keys move between cards and pick, as a radio group's
60
- * do, and only the picked card is in the tab order.
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. The
27
- * selected card carries the primary color, and a picture card a check over
28
- * its picture; the arrow keys move between cards and pick, as a radio group's
29
- * do, and only the picked card is in the tab order.
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: 1.5,
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__*/ _jsx(Card, {
132
+ return /*#__PURE__*/ _jsxs(Card, {
72
133
  variant: "outlined",
73
- sx: (theme)=>({
74
- borderRadius: 2,
75
- borderColor: selected ? 'primary.main' : 'divider',
76
- bgcolor: selected ? alpha(theme.palette.primary.main, 0.08) : 'transparent'
77
- }),
78
- children: /*#__PURE__*/ _jsxs(CardActionArea, {
79
- ref: (node)=>{
80
- refs.current[index] = node;
81
- },
82
- role: "radio",
83
- "aria-checked": selected,
84
- "aria-label": option.description ? `${option.title}: ${option.description}` : option.title,
85
- tabIndex: index === focusable ? 0 : -1,
86
- onClick: ()=>onChange(option.id),
87
- onKeyDown: (event)=>onKeyDown(event, index),
88
- sx: {
89
- p: option.image ? 0 : 2,
90
- height: '100%',
91
- minHeight: 44,
92
- display: 'flex',
93
- flexDirection: option.image ? 'column' : 'row',
94
- alignItems: option.image ? 'stretch' : 'flex-start',
95
- justifyContent: 'flex-start'
96
- },
97
- children: [
98
- option.image && /*#__PURE__*/ _jsxs(Box, {
99
- sx: {
100
- position: 'relative',
101
- width: '100%'
102
- },
103
- children: [
104
- /*#__PURE__*/ _jsx(Box, {
105
- component: "img",
106
- src: option.image.src,
107
- alt: option.image.alt,
108
- width: option.image.width,
109
- height: option.image.height,
110
- loading: "lazy",
111
- decoding: "async",
112
- sx: {
113
- display: 'block',
114
- width: '100%',
115
- height: 'auto',
116
- aspectRatio: `${option.image.width} / ${option.image.height}`,
117
- objectFit: 'cover',
118
- objectPosition: 'top',
119
- borderBottom: 1,
120
- borderColor: 'divider',
121
- bgcolor: 'action.hover'
122
- }
123
- }),
124
- selected && /*#__PURE__*/ _jsx(Box, {
125
- "aria-hidden": true,
126
- sx: {
127
- position: 'absolute',
128
- top: 8,
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
- fontSize: 22
165
- }
166
- })
167
- }),
168
- /*#__PURE__*/ _jsxs(Box, {
169
- sx: {
170
- flex: 1,
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
- display: 'block'
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"}