@adia-ai/web-components 0.0.14 → 0.0.16
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/README.md +43 -1
- package/components/alert/alert.css +5 -0
- package/components/alert/alert.js +4 -2
- package/components/button/button.js +4 -1
- package/components/chart/chart.js +7 -4
- package/components/chat/chat-input.js +13 -2
- package/components/description-list/description-list.js +4 -3
- package/components/field/field.css +113 -63
- package/components/field/field.js +44 -142
- package/components/icon/icon.a2ui.json +1 -1
- package/components/icon/icon.css +16 -0
- package/components/icon/icon.js +18 -0
- package/components/icon/icon.yaml +6 -2
- package/components/index.js +7 -0
- package/components/input/input.a2ui.json +1 -1
- package/components/input/input.css +21 -23
- package/components/input/input.js +36 -9
- package/components/input/input.yaml +3 -1
- package/components/option-card/option-card.a2ui.json +262 -0
- package/components/option-card/option-card.css +215 -0
- package/components/option-card/option-card.js +158 -0
- package/components/option-card/option-card.yaml +234 -0
- package/components/rating/rating.a2ui.json +10 -0
- package/components/rating/rating.yaml +8 -0
- package/components/segment/segment.a2ui.json +5 -0
- package/components/segment/segment.css +2 -0
- package/components/segment/segment.js +21 -1
- package/components/segment/segment.yaml +5 -0
- package/components/textarea/textarea.css +3 -1
- package/components/textarea/textarea.js +2 -2
- package/components/tooltip/tooltip.js +10 -3
- package/core/data-stream.js +486 -0
- package/core/form.js +5 -0
- package/core/index.js +2 -0
- package/core/streams-bridge.js +96 -0
- package/package.json +1 -1
- package/styles/colors/semantics.css +21 -3
- package/styles/components.css +1 -0
- package/styles/prose.css +3 -7
- package/styles/tokens.css +7 -4
- package/styles/typography.css +6 -1
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://adiaui.dev/a2ui/v0_9/components/OptionCard.json",
|
|
4
|
+
"title": "OptionCard",
|
|
5
|
+
"description": "Selectable card with radio semantics. A \"rich radio\" — single-select one of N where each option carries a heading, optional description, and optional leading icon. Siblings sharing a `name` form a radiogroup. The whole card is the click target; a CSS-rendered radio circle in the top-left signals state. Form-associated, so `name=value` submits with the parent form when checked.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"allOf": [
|
|
8
|
+
{
|
|
9
|
+
"$ref": "common_types.json#/$defs/ComponentCommon"
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"$ref": "common_types.json#/$defs/CatalogComponentCommon"
|
|
13
|
+
}
|
|
14
|
+
],
|
|
15
|
+
"properties": {
|
|
16
|
+
"description": {
|
|
17
|
+
"description": "Description text. Stamped into a [slot=\"description\"] span when no slotted description is provided.",
|
|
18
|
+
"type": "string",
|
|
19
|
+
"default": ""
|
|
20
|
+
},
|
|
21
|
+
"required": {
|
|
22
|
+
"description": "Marks the radiogroup as requiring a selection for form validation.",
|
|
23
|
+
"type": "boolean",
|
|
24
|
+
"default": false
|
|
25
|
+
},
|
|
26
|
+
"checked": {
|
|
27
|
+
"description": "Whether this card is currently selected.",
|
|
28
|
+
"type": "boolean",
|
|
29
|
+
"default": false
|
|
30
|
+
},
|
|
31
|
+
"component": {
|
|
32
|
+
"const": "OptionCard"
|
|
33
|
+
},
|
|
34
|
+
"disabled": {
|
|
35
|
+
"description": "Disables interaction and dims the card.",
|
|
36
|
+
"type": "boolean",
|
|
37
|
+
"default": false
|
|
38
|
+
},
|
|
39
|
+
"heading": {
|
|
40
|
+
"description": "Heading text. Stamped into a [slot=\"heading\"] span when no slotted heading is provided.",
|
|
41
|
+
"type": "string",
|
|
42
|
+
"default": ""
|
|
43
|
+
},
|
|
44
|
+
"icon": {
|
|
45
|
+
"description": "Optional Phosphor icon name. Stamped as a leading <icon-ui slot=\"icon\"> when set.",
|
|
46
|
+
"type": "string",
|
|
47
|
+
"default": ""
|
|
48
|
+
},
|
|
49
|
+
"layout": {
|
|
50
|
+
"description": "Internal layout. `default` puts the indicator on the left and the icon adjacent. `tile` stacks vertically — icon top-left, indicator top-right, heading + description below — for hero pickers (source / role / plan tiles) where the icon is a primary brand cue.",
|
|
51
|
+
"type": "string",
|
|
52
|
+
"enum": [
|
|
53
|
+
"default",
|
|
54
|
+
"tile"
|
|
55
|
+
],
|
|
56
|
+
"default": "default"
|
|
57
|
+
},
|
|
58
|
+
"name": {
|
|
59
|
+
"description": "Form control name. Siblings sharing a name form a radiogroup.",
|
|
60
|
+
"type": "string",
|
|
61
|
+
"default": ""
|
|
62
|
+
},
|
|
63
|
+
"value": {
|
|
64
|
+
"description": "Form value submitted when checked (defaults to `on` if empty).",
|
|
65
|
+
"type": "string",
|
|
66
|
+
"default": ""
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
"required": [
|
|
70
|
+
"component"
|
|
71
|
+
],
|
|
72
|
+
"unevaluatedProperties": false,
|
|
73
|
+
"x-adiaui": {
|
|
74
|
+
"anti_patterns": [],
|
|
75
|
+
"category": "input",
|
|
76
|
+
"events": {
|
|
77
|
+
"change": {
|
|
78
|
+
"description": "Fired when this card becomes selected (bubbles)."
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
"examples": [
|
|
82
|
+
{
|
|
83
|
+
"description": "A four-option pick-one for \"what brings you here\" — radio-card behavior with heading + description per option.",
|
|
84
|
+
"a2ui": "[\n {\n \"id\": \"root\",\n \"component\": \"Column\",\n \"gap\": \"2\",\n \"children\": [\"build\", \"explore\", \"migrate\", \"evaluate\"]\n },\n {\n \"id\": \"build\",\n \"component\": \"OptionCard\",\n \"name\": \"use-case\",\n \"value\": \"build\",\n \"checked\": true,\n \"heading\": \"I'm building a product\",\n \"description\": \"Spinning up a new project — design, ship, iterate.\"\n },\n {\n \"id\": \"explore\",\n \"component\": \"OptionCard\",\n \"name\": \"use-case\",\n \"value\": \"explore\",\n \"heading\": \"I'm exploring the product\",\n \"description\": \"Kicking the tires before bringing a team along.\"\n },\n {\n \"id\": \"migrate\",\n \"component\": \"OptionCard\",\n \"name\": \"use-case\",\n \"value\": \"migrate\",\n \"heading\": \"I'm migrating from another tool\",\n \"description\": \"Moving an existing workspace and want a smooth port.\"\n },\n {\n \"id\": \"evaluate\",\n \"component\": \"OptionCard\",\n \"name\": \"use-case\",\n \"value\": \"evaluate\",\n \"heading\": \"I'm evaluating for my team\",\n \"description\": \"Comparing options and want to dig into specifics.\"\n }\n]",
|
|
85
|
+
"name": "use-case-picker"
|
|
86
|
+
}
|
|
87
|
+
],
|
|
88
|
+
"keywords": [
|
|
89
|
+
"option",
|
|
90
|
+
"card",
|
|
91
|
+
"radio",
|
|
92
|
+
"select",
|
|
93
|
+
"choice",
|
|
94
|
+
"picker",
|
|
95
|
+
"tier",
|
|
96
|
+
"plan",
|
|
97
|
+
"onboarding",
|
|
98
|
+
"registration"
|
|
99
|
+
],
|
|
100
|
+
"name": "AdiaOptionCard",
|
|
101
|
+
"related": [
|
|
102
|
+
"radio",
|
|
103
|
+
"card",
|
|
104
|
+
"check",
|
|
105
|
+
"segmented"
|
|
106
|
+
],
|
|
107
|
+
"slots": {
|
|
108
|
+
"description": {
|
|
109
|
+
"description": "Rich description content. Overrides the `description` attribute when present."
|
|
110
|
+
},
|
|
111
|
+
"default": {
|
|
112
|
+
"description": "Spillover content revealed only when the card is checked — typically a follow-up form field (e.g. a textarea on an \"Other\" option, conditional inputs that depend on the selection). Aligns with the heading/description column; hidden via `display: none` when not checked."
|
|
113
|
+
},
|
|
114
|
+
"heading": {
|
|
115
|
+
"description": "Rich heading content. Overrides the `heading` attribute when present."
|
|
116
|
+
},
|
|
117
|
+
"icon": {
|
|
118
|
+
"description": "Custom icon element. Overrides the `icon` attribute when present."
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
"states": [
|
|
122
|
+
{
|
|
123
|
+
"description": "Default, ready for interaction.",
|
|
124
|
+
"name": "idle"
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
"description": "Pointer over a non-checked card.",
|
|
128
|
+
"name": "hover",
|
|
129
|
+
"selector": ":not([checked]):not([disabled]):hover"
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
"description": "Selected — accent border, tinted background, filled radio circle.",
|
|
133
|
+
"attribute": "checked",
|
|
134
|
+
"name": "checked"
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
"description": "Non-interactive; dimmed.",
|
|
138
|
+
"attribute": "disabled",
|
|
139
|
+
"name": "disabled"
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
"description": "Keyboard focus ring.",
|
|
143
|
+
"name": "focused",
|
|
144
|
+
"selector": ":focus-visible"
|
|
145
|
+
}
|
|
146
|
+
],
|
|
147
|
+
"synonyms": {
|
|
148
|
+
"picker": [
|
|
149
|
+
"option-card",
|
|
150
|
+
"radio",
|
|
151
|
+
"select"
|
|
152
|
+
],
|
|
153
|
+
"radio": [
|
|
154
|
+
"option-card",
|
|
155
|
+
"radio",
|
|
156
|
+
"select"
|
|
157
|
+
],
|
|
158
|
+
"tier": [
|
|
159
|
+
"option-card",
|
|
160
|
+
"card",
|
|
161
|
+
"plan"
|
|
162
|
+
]
|
|
163
|
+
},
|
|
164
|
+
"tag": "option-card-ui",
|
|
165
|
+
"tokens": {
|
|
166
|
+
"--option-card-bg": {
|
|
167
|
+
"description": "Default background."
|
|
168
|
+
},
|
|
169
|
+
"--option-card-bg-checked": {
|
|
170
|
+
"description": "Background when checked."
|
|
171
|
+
},
|
|
172
|
+
"--option-card-bg-hover": {
|
|
173
|
+
"description": "Hover background (non-checked)."
|
|
174
|
+
},
|
|
175
|
+
"--option-card-border": {
|
|
176
|
+
"description": "Default border color."
|
|
177
|
+
},
|
|
178
|
+
"--option-card-border-checked": {
|
|
179
|
+
"description": "Border color when checked."
|
|
180
|
+
},
|
|
181
|
+
"--option-card-border-hover": {
|
|
182
|
+
"description": "Hover border color (non-checked)."
|
|
183
|
+
},
|
|
184
|
+
"--option-card-desc-color": {
|
|
185
|
+
"description": "Description text color."
|
|
186
|
+
},
|
|
187
|
+
"--option-card-desc-line-height": {
|
|
188
|
+
"description": "Description line height."
|
|
189
|
+
},
|
|
190
|
+
"--option-card-desc-size": {
|
|
191
|
+
"description": "Description font size."
|
|
192
|
+
},
|
|
193
|
+
"--option-card-disabled-opacity": {
|
|
194
|
+
"description": "Opacity multiplier when disabled."
|
|
195
|
+
},
|
|
196
|
+
"--option-card-duration": {
|
|
197
|
+
"description": "Transition duration for hover / checked state changes."
|
|
198
|
+
},
|
|
199
|
+
"--option-card-easing": {
|
|
200
|
+
"description": "Transition easing."
|
|
201
|
+
},
|
|
202
|
+
"--option-card-focus-ring": {
|
|
203
|
+
"description": "Focus ring (box-shadow value)."
|
|
204
|
+
},
|
|
205
|
+
"--option-card-gap-x": {
|
|
206
|
+
"description": "Horizontal gap between indicator (and icon) and content."
|
|
207
|
+
},
|
|
208
|
+
"--option-card-gap-y": {
|
|
209
|
+
"description": "Vertical gap between heading and description."
|
|
210
|
+
},
|
|
211
|
+
"--option-card-heading-color": {
|
|
212
|
+
"description": "Heading text color."
|
|
213
|
+
},
|
|
214
|
+
"--option-card-heading-color-checked": {
|
|
215
|
+
"description": "Heading text color when the card is checked (defaults to `--a-fg-strong` so the selected card reads with extra emphasis on top of the bg/border state)."
|
|
216
|
+
},
|
|
217
|
+
"--option-card-heading-size": {
|
|
218
|
+
"description": "Heading font size."
|
|
219
|
+
},
|
|
220
|
+
"--option-card-heading-weight": {
|
|
221
|
+
"description": "Heading font weight."
|
|
222
|
+
},
|
|
223
|
+
"--option-card-icon-color": {
|
|
224
|
+
"description": "Leading icon color when the card is not checked."
|
|
225
|
+
},
|
|
226
|
+
"--option-card-icon-color-checked": {
|
|
227
|
+
"description": "Leading icon color when the card is checked."
|
|
228
|
+
},
|
|
229
|
+
"--option-card-icon-size": {
|
|
230
|
+
"description": "Leading icon size (sets `--a-icon-size` on the slotted icon-ui)."
|
|
231
|
+
},
|
|
232
|
+
"--option-card-padding-block": {
|
|
233
|
+
"description": "Vertical padding inside the card."
|
|
234
|
+
},
|
|
235
|
+
"--option-card-padding-inline": {
|
|
236
|
+
"description": "Horizontal padding inside the card."
|
|
237
|
+
},
|
|
238
|
+
"--option-card-radio-bg": {
|
|
239
|
+
"description": "Indicator background when not checked."
|
|
240
|
+
},
|
|
241
|
+
"--option-card-radio-border": {
|
|
242
|
+
"description": "Indicator border when not checked."
|
|
243
|
+
},
|
|
244
|
+
"--option-card-radio-dot": {
|
|
245
|
+
"description": "Inner dot color when checked."
|
|
246
|
+
},
|
|
247
|
+
"--option-card-radio-fill": {
|
|
248
|
+
"description": "Indicator fill color when checked."
|
|
249
|
+
},
|
|
250
|
+
"--option-card-radio-size": {
|
|
251
|
+
"description": "Diameter of the indicator circle."
|
|
252
|
+
},
|
|
253
|
+
"--option-card-radius": {
|
|
254
|
+
"description": "Card corner radius."
|
|
255
|
+
}
|
|
256
|
+
},
|
|
257
|
+
"traits": [
|
|
258
|
+
"focusable"
|
|
259
|
+
],
|
|
260
|
+
"version": 1
|
|
261
|
+
}
|
|
262
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
@scope (option-card-ui) {
|
|
2
|
+
:where(:scope) {
|
|
3
|
+
/* ── Container ── */
|
|
4
|
+
--option-card-padding-block: var(--a-space-3);
|
|
5
|
+
--option-card-padding-inline: var(--a-space-4);
|
|
6
|
+
--option-card-radius: var(--a-radius-md);
|
|
7
|
+
--option-card-bg: var(--a-bg);
|
|
8
|
+
--option-card-border: var(--a-border);
|
|
9
|
+
--option-card-gap-x: var(--a-space-3);
|
|
10
|
+
--option-card-gap-y: var(--a-space-1);
|
|
11
|
+
|
|
12
|
+
/* ── State: hover ── */
|
|
13
|
+
--option-card-bg-hover: var(--a-bg-muted);
|
|
14
|
+
--option-card-border-hover: var(--a-fg-subtle);
|
|
15
|
+
|
|
16
|
+
/* ── State: checked ── */
|
|
17
|
+
--option-card-bg-checked: var(--a-accent-muted);
|
|
18
|
+
--option-card-border-checked: var(--a-accent);
|
|
19
|
+
|
|
20
|
+
/* ── Indicator (CSS radio circle, same size + recipe as <radio-ui>). */
|
|
21
|
+
--option-card-radio-size: var(--a-toggle-size);
|
|
22
|
+
--option-card-radio-bg: var(--a-bg);
|
|
23
|
+
--option-card-radio-border: var(--a-border);
|
|
24
|
+
--option-card-radio-fill: var(--a-accent);
|
|
25
|
+
--option-card-radio-dot: var(--a-accent-fg);
|
|
26
|
+
|
|
27
|
+
/* ── Typography ── */
|
|
28
|
+
--option-card-heading-color: var(--a-fg);
|
|
29
|
+
--option-card-heading-color-checked: var(--a-fg-strong);
|
|
30
|
+
--option-card-heading-weight: var(--a-weight-medium);
|
|
31
|
+
--option-card-heading-size: var(--a-ui-size);
|
|
32
|
+
--option-card-desc-color: var(--a-fg-muted);
|
|
33
|
+
--option-card-desc-size: var(--a-ui-sm);
|
|
34
|
+
--option-card-desc-line-height: 1.4;
|
|
35
|
+
--option-card-icon-color: var(--a-fg-subtle);
|
|
36
|
+
--option-card-icon-color-checked: var(--a-fg-strong);
|
|
37
|
+
--option-card-icon-size: 1.5rem;
|
|
38
|
+
|
|
39
|
+
/* ── State: disabled ── */
|
|
40
|
+
--option-card-disabled-opacity: 0.6;
|
|
41
|
+
|
|
42
|
+
/* ── Focus ── */
|
|
43
|
+
--option-card-focus-ring: var(--a-focus-ring);
|
|
44
|
+
|
|
45
|
+
/* ── Transitions ── */
|
|
46
|
+
--option-card-duration: var(--a-duration-fast);
|
|
47
|
+
--option-card-easing: var(--a-easing);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/* ── Base — grid: indicator | (heading + description). Optional icon
|
|
51
|
+
sits between indicator and heading via :has() rules below. ── */
|
|
52
|
+
:scope {
|
|
53
|
+
box-sizing: border-box;
|
|
54
|
+
display: grid;
|
|
55
|
+
grid-template-columns: auto minmax(0, 1fr);
|
|
56
|
+
grid-template-areas:
|
|
57
|
+
"indicator heading"
|
|
58
|
+
"indicator description";
|
|
59
|
+
column-gap: var(--option-card-gap-x);
|
|
60
|
+
row-gap: var(--option-card-gap-y);
|
|
61
|
+
padding: var(--option-card-padding-block) var(--option-card-padding-inline);
|
|
62
|
+
border: 1px solid var(--option-card-border);
|
|
63
|
+
border-radius: var(--option-card-radius);
|
|
64
|
+
background: var(--option-card-bg);
|
|
65
|
+
cursor: pointer;
|
|
66
|
+
user-select: none;
|
|
67
|
+
outline: none;
|
|
68
|
+
transition:
|
|
69
|
+
background var(--option-card-duration) var(--option-card-easing),
|
|
70
|
+
border-color var(--option-card-duration) var(--option-card-easing);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/* When an icon slot is present, insert a third column for it
|
|
74
|
+
between indicator and heading. */
|
|
75
|
+
:scope:has(> [slot="icon"]) {
|
|
76
|
+
grid-template-columns: auto auto minmax(0, 1fr);
|
|
77
|
+
grid-template-areas:
|
|
78
|
+
"indicator icon heading"
|
|
79
|
+
"indicator icon description";
|
|
80
|
+
}
|
|
81
|
+
:scope > [slot="icon"] {
|
|
82
|
+
grid-area: icon;
|
|
83
|
+
align-self: start;
|
|
84
|
+
color: var(--option-card-icon-color);
|
|
85
|
+
--a-icon-size: var(--option-card-icon-size);
|
|
86
|
+
transition: color var(--option-card-duration) var(--option-card-easing);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/* ── Indicator — pure CSS radio circle, no separate <radio-ui>. */
|
|
90
|
+
:scope::before {
|
|
91
|
+
content: '';
|
|
92
|
+
grid-area: indicator;
|
|
93
|
+
width: var(--option-card-radio-size);
|
|
94
|
+
height: var(--option-card-radio-size);
|
|
95
|
+
border: 1.5px solid var(--option-card-radio-border);
|
|
96
|
+
border-radius: var(--a-radius-full);
|
|
97
|
+
background: var(--option-card-radio-bg);
|
|
98
|
+
align-self: start;
|
|
99
|
+
margin-block-start: 0.125rem;
|
|
100
|
+
flex-shrink: 0;
|
|
101
|
+
transition:
|
|
102
|
+
background var(--option-card-duration) var(--option-card-easing),
|
|
103
|
+
border-color var(--option-card-duration) var(--option-card-easing);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/* ── Slots — heading + description ── */
|
|
107
|
+
:scope > [slot="heading"] {
|
|
108
|
+
grid-area: heading;
|
|
109
|
+
color: var(--option-card-heading-color);
|
|
110
|
+
font-weight: var(--option-card-heading-weight);
|
|
111
|
+
font-size: var(--option-card-heading-size);
|
|
112
|
+
transition: color var(--option-card-duration) var(--option-card-easing);
|
|
113
|
+
}
|
|
114
|
+
:scope > [slot="description"] {
|
|
115
|
+
grid-area: description;
|
|
116
|
+
color: var(--option-card-desc-color);
|
|
117
|
+
font-size: var(--option-card-desc-size);
|
|
118
|
+
line-height: var(--option-card-desc-line-height);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/* ── Default slot — "spillover" content revealed when checked.
|
|
122
|
+
Aligns with the heading/description column (skips the indicator
|
|
123
|
+
gutter). Common pattern: an "Other" option with a free-text
|
|
124
|
+
textarea, conditional follow-up fields, etc. ── */
|
|
125
|
+
:scope > :not([slot]) {
|
|
126
|
+
grid-column: 2 / -1;
|
|
127
|
+
margin-block-start: var(--a-space-3);
|
|
128
|
+
display: none;
|
|
129
|
+
}
|
|
130
|
+
:scope:has(> [slot="icon"]) > :not([slot]) {
|
|
131
|
+
grid-column: 3 / -1;
|
|
132
|
+
}
|
|
133
|
+
:scope[checked] > :not([slot]) {
|
|
134
|
+
display: block;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/* ── State: hover (not checked, not disabled) ── */
|
|
138
|
+
:scope:not([checked]):not([disabled]):hover {
|
|
139
|
+
background: var(--option-card-bg-hover);
|
|
140
|
+
border-color: var(--option-card-border-hover);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/* ── State: checked — accent border + tinted bg + filled radio.
|
|
144
|
+
The indicator becomes an accent disc with a centered dot of
|
|
145
|
+
--option-card-radio-dot at 60% of the size, mirroring
|
|
146
|
+
radio-ui's recipe (radio.css:75-78). Done with a radial
|
|
147
|
+
gradient so a single pseudo-element carries both layers. */
|
|
148
|
+
:scope[checked] {
|
|
149
|
+
background: var(--option-card-bg-checked);
|
|
150
|
+
border-color: var(--option-card-border-checked);
|
|
151
|
+
}
|
|
152
|
+
:scope[checked]::before {
|
|
153
|
+
border-color: var(--option-card-radio-fill);
|
|
154
|
+
background:
|
|
155
|
+
radial-gradient(
|
|
156
|
+
circle,
|
|
157
|
+
var(--option-card-radio-dot) 0 30%,
|
|
158
|
+
var(--option-card-radio-fill) 30% 100%
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
/* Heading + icon shift to a strong color when checked — gives the
|
|
162
|
+
selected card a clear text-level emphasis on top of the bg/border
|
|
163
|
+
state, so picking is unambiguous beyond the radio dot alone. */
|
|
164
|
+
:scope[checked] > [slot="heading"] {
|
|
165
|
+
color: var(--option-card-heading-color-checked);
|
|
166
|
+
}
|
|
167
|
+
:scope[checked] > [slot="icon"] {
|
|
168
|
+
color: var(--option-card-icon-color-checked);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/* ── Layout: tile — icon top-left, indicator top-right, heading +
|
|
172
|
+
description below, all left-aligned. Used for hero pickers
|
|
173
|
+
(data source, role, plan tiles) where the icon is a primary
|
|
174
|
+
brand cue rather than secondary chrome. ── */
|
|
175
|
+
:scope[layout="tile"] {
|
|
176
|
+
grid-template-columns: minmax(0, 1fr) auto;
|
|
177
|
+
grid-template-areas:
|
|
178
|
+
"icon indicator"
|
|
179
|
+
"heading heading"
|
|
180
|
+
"description description";
|
|
181
|
+
column-gap: var(--option-card-gap-x);
|
|
182
|
+
row-gap: var(--option-card-gap-y);
|
|
183
|
+
padding: var(--a-space-4);
|
|
184
|
+
align-items: start;
|
|
185
|
+
}
|
|
186
|
+
:scope[layout="tile"] > [slot="icon"] {
|
|
187
|
+
grid-area: icon;
|
|
188
|
+
justify-self: start;
|
|
189
|
+
align-self: start;
|
|
190
|
+
--option-card-icon-size: 1.75rem;
|
|
191
|
+
}
|
|
192
|
+
:scope[layout="tile"]::before {
|
|
193
|
+
grid-area: indicator;
|
|
194
|
+
align-self: start;
|
|
195
|
+
justify-self: end;
|
|
196
|
+
margin-block-start: 0;
|
|
197
|
+
}
|
|
198
|
+
:scope[layout="tile"] > [slot="heading"] {
|
|
199
|
+
margin-block-start: var(--a-space-2);
|
|
200
|
+
}
|
|
201
|
+
:scope[layout="tile"] > :not([slot]) {
|
|
202
|
+
grid-column: 1 / -1;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/* ── State: disabled ── */
|
|
206
|
+
:scope[disabled] {
|
|
207
|
+
cursor: not-allowed;
|
|
208
|
+
opacity: var(--option-card-disabled-opacity);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/* ── Focus ── */
|
|
212
|
+
:scope:focus-visible {
|
|
213
|
+
box-shadow: var(--option-card-focus-ring);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <option-card-ui> — Selectable card with radio semantics.
|
|
3
|
+
*
|
|
4
|
+
* A "rich radio" — single-select-of-N where each option carries a
|
|
5
|
+
* heading, optional description, and optional leading icon. Siblings
|
|
6
|
+
* with the same `name` form a radiogroup. The whole card is the click
|
|
7
|
+
* target; a CSS-rendered radio circle in the top-left signals state.
|
|
8
|
+
*
|
|
9
|
+
* <option-card-ui name="use-case" value="build" checked
|
|
10
|
+
* heading="I'm building a product"
|
|
11
|
+
* description="Spinning up a new project — design, ship, iterate.">
|
|
12
|
+
* </option-card-ui>
|
|
13
|
+
*
|
|
14
|
+
* Rich content via slots:
|
|
15
|
+
*
|
|
16
|
+
* <option-card-ui name="plan" value="pro">
|
|
17
|
+
* <span slot="heading">Pro <badge-ui text="14-day trial"></badge-ui></span>
|
|
18
|
+
* <span slot="description">Unlimited members · advanced charts</span>
|
|
19
|
+
* </option-card-ui>
|
|
20
|
+
*
|
|
21
|
+
* Spillover content via the default slot — any unslotted child shows
|
|
22
|
+
* only when the card is `[checked]`. Used for "Other"-style fields:
|
|
23
|
+
*
|
|
24
|
+
* <option-card-ui name="reason" value="other"
|
|
25
|
+
* heading="Something else">
|
|
26
|
+
* <textarea-ui name="reason-detail" rows="3"></textarea-ui>
|
|
27
|
+
* </option-card-ui>
|
|
28
|
+
*
|
|
29
|
+
* Sibling navigation: arrow keys move focus and selection; Space/Enter
|
|
30
|
+
* select. Form-associated via AdiaFormElement, so `name=value` submits
|
|
31
|
+
* with the parent form when the card is checked.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { AdiaFormElement } from '../../core/form.js';
|
|
35
|
+
|
|
36
|
+
class AdiaOptionCard extends AdiaFormElement {
|
|
37
|
+
static properties = {
|
|
38
|
+
...AdiaFormElement.properties,
|
|
39
|
+
checked: { type: Boolean, default: false, reflect: true },
|
|
40
|
+
heading: { type: String, default: '', reflect: true },
|
|
41
|
+
description: { type: String, default: '', reflect: true },
|
|
42
|
+
icon: { type: String, default: '', reflect: true },
|
|
43
|
+
layout: { type: String, default: 'default', reflect: true },
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
static template = () => null;
|
|
47
|
+
|
|
48
|
+
connected() {
|
|
49
|
+
super.connected();
|
|
50
|
+
this.setAttribute('role', 'radio');
|
|
51
|
+
this.setAttribute('tabindex', '0');
|
|
52
|
+
this.#ensureLayout();
|
|
53
|
+
this.addEventListener('click', this.#select);
|
|
54
|
+
this.addEventListener('keydown', this.#onKey);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
disconnected() {
|
|
58
|
+
super.disconnected();
|
|
59
|
+
this.removeEventListener('click', this.#select);
|
|
60
|
+
this.removeEventListener('keydown', this.#onKey);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
render() {
|
|
64
|
+
this.setAttribute('aria-checked', String(this.checked));
|
|
65
|
+
if (this.disabled) this.setAttribute('aria-disabled', 'true');
|
|
66
|
+
else this.removeAttribute('aria-disabled');
|
|
67
|
+
|
|
68
|
+
// Keep slotted content in sync with attrs (only when slot was
|
|
69
|
+
// auto-stamped from the attr — don't clobber consumer-authored
|
|
70
|
+
// rich content).
|
|
71
|
+
const h = this.querySelector(':scope > [slot="heading"]');
|
|
72
|
+
if (h && h.dataset.fromAttr === 'true') h.textContent = this.heading || '';
|
|
73
|
+
const d = this.querySelector(':scope > [slot="description"]');
|
|
74
|
+
if (d && d.dataset.fromAttr === 'true') d.textContent = this.description || '';
|
|
75
|
+
const i = this.querySelector(':scope > [slot="icon"]');
|
|
76
|
+
if (i && i.dataset.fromAttr === 'true') i.setAttribute('name', this.icon || '');
|
|
77
|
+
|
|
78
|
+
if (this.checked) this.syncValue(this.value || 'on');
|
|
79
|
+
else this.syncValue('');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ── Private ───────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
/** Stamp heading / description / icon from attributes when the
|
|
85
|
+
* consumer hasn't already provided slotted content. */
|
|
86
|
+
#ensureLayout() {
|
|
87
|
+
if (this.heading && !this.querySelector(':scope > [slot="heading"]')) {
|
|
88
|
+
const el = document.createElement('span');
|
|
89
|
+
el.setAttribute('slot', 'heading');
|
|
90
|
+
el.dataset.fromAttr = 'true';
|
|
91
|
+
el.textContent = this.heading;
|
|
92
|
+
this.appendChild(el);
|
|
93
|
+
}
|
|
94
|
+
if (this.description && !this.querySelector(':scope > [slot="description"]')) {
|
|
95
|
+
const el = document.createElement('span');
|
|
96
|
+
el.setAttribute('slot', 'description');
|
|
97
|
+
el.dataset.fromAttr = 'true';
|
|
98
|
+
el.textContent = this.description;
|
|
99
|
+
this.appendChild(el);
|
|
100
|
+
}
|
|
101
|
+
if (this.icon && !this.querySelector(':scope > [slot="icon"]')) {
|
|
102
|
+
const el = document.createElement('icon-ui');
|
|
103
|
+
el.setAttribute('slot', 'icon');
|
|
104
|
+
el.dataset.fromAttr = 'true';
|
|
105
|
+
el.setAttribute('name', this.icon);
|
|
106
|
+
this.appendChild(el);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
#select = () => {
|
|
111
|
+
if (this.disabled || this.readonly || this.checked) return;
|
|
112
|
+
const group = this.#group();
|
|
113
|
+
if (group) {
|
|
114
|
+
for (const el of group.querySelectorAll(`option-card-ui[name="${this.name}"]`)) {
|
|
115
|
+
if (el !== this && el.checked) el.checked = false;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
this.checked = true;
|
|
119
|
+
this.dispatchEvent(new Event('change', { bubbles: true }));
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
#onKey = (e) => {
|
|
123
|
+
// Don't intercept keys when focus is in a nested form control
|
|
124
|
+
// (textarea, input, contenteditable). Otherwise Space/Enter would
|
|
125
|
+
// block typing and arrow keys would navigate siblings instead of
|
|
126
|
+
// moving the cursor.
|
|
127
|
+
if (e.target !== this) return;
|
|
128
|
+
if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); this.#select(); return; }
|
|
129
|
+
if (e.key === 'ArrowDown' || e.key === 'ArrowRight') {
|
|
130
|
+
e.preventDefault();
|
|
131
|
+
const next = this.#sibling(1);
|
|
132
|
+
if (next) { next.focus(); next.click(); }
|
|
133
|
+
}
|
|
134
|
+
if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') {
|
|
135
|
+
e.preventDefault();
|
|
136
|
+
const prev = this.#sibling(-1);
|
|
137
|
+
if (prev) { prev.focus(); prev.click(); }
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/** The radiogroup root — explicit `<fieldset>` / `[role="radiogroup"]`
|
|
142
|
+
* if present, else the immediate parent. */
|
|
143
|
+
#group() {
|
|
144
|
+
return this.closest('fieldset, [role="radiogroup"]') || this.parentElement;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
#sibling(dir) {
|
|
148
|
+
const group = this.#group();
|
|
149
|
+
const items = [...(group?.querySelectorAll(`option-card-ui[name="${this.name}"]`) || [])];
|
|
150
|
+
const i = items.indexOf(this);
|
|
151
|
+
if (i < 0) return null;
|
|
152
|
+
return items[(i + dir + items.length) % items.length] || null;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
customElements.define('option-card-ui', AdiaOptionCard);
|
|
157
|
+
|
|
158
|
+
export { AdiaOptionCard };
|