@adia-ai/web-components 0.4.3 → 0.4.5
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/components/alert/alert.a2ui.json +17 -2
- package/components/alert/alert.js +100 -9
- package/components/alert/alert.test.js +180 -0
- package/components/alert/alert.yaml +30 -2
- package/components/badge/badge.a2ui.json +4 -0
- package/components/badge/badge.js +1 -0
- package/components/badge/badge.yaml +4 -0
- package/components/button/button.a2ui.json +14 -4
- package/components/button/button.js +1 -0
- package/components/button/button.yaml +18 -3
- package/components/calendar-picker/calendar-picker.js +1 -1
- package/components/check/check.a2ui.json +8 -1
- package/components/check/check.js +1 -1
- package/components/check/check.yaml +11 -2
- package/components/code/code.a2ui.json +4 -0
- package/components/code/code.js +1 -0
- package/components/code/code.yaml +4 -0
- package/components/col/col.a2ui.json +5 -0
- package/components/col/col.js +1 -0
- package/components/col/col.yaml +5 -0
- package/components/field/field.a2ui.json +17 -6
- package/components/field/field.test.js +8 -2
- package/components/field/field.yaml +50 -8
- package/components/index.js +1 -0
- package/components/input/input.a2ui.json +20 -0
- package/components/input/input.js +9 -9
- package/components/input/input.yaml +15 -0
- package/components/link/link.a2ui.json +166 -0
- package/components/link/link.css +102 -0
- package/components/link/link.js +177 -0
- package/components/link/link.test.js +143 -0
- package/components/link/link.yaml +162 -0
- package/components/option-card/option-card.js +1 -1
- package/components/otp-input/otp-input.js +3 -3
- package/components/radio/radio.a2ui.json +8 -1
- package/components/radio/radio.js +1 -1
- package/components/radio/radio.yaml +11 -2
- package/components/range/range.js +3 -3
- package/components/rating/rating.js +1 -1
- package/components/row/row.a2ui.json +5 -0
- package/components/row/row.js +1 -0
- package/components/row/row.yaml +5 -0
- package/components/search/search.js +2 -2
- package/components/select/select.a2ui.json +15 -0
- package/components/select/select.js +2 -2
- package/components/select/select.yaml +14 -0
- package/components/slider/slider.js +4 -4
- package/components/slider/slider.test.js +105 -0
- package/components/switch/switch.a2ui.json +8 -1
- package/components/switch/switch.js +1 -1
- package/components/switch/switch.yaml +11 -2
- package/components/table/table.a2ui.json +10 -0
- package/components/table/table.yaml +8 -0
- package/components/tag/tag.a2ui.json +4 -0
- package/components/tag/tag.js +1 -0
- package/components/tag/tag.yaml +4 -0
- package/components/text/text.a2ui.json +5 -0
- package/components/text/text.js +1 -0
- package/components/text/text.yaml +5 -0
- package/components/textarea/textarea.a2ui.json +5 -0
- package/components/textarea/textarea.js +2 -2
- package/components/textarea/textarea.yaml +4 -0
- package/components/upload/upload.js +1 -1
- package/package.json +2 -1
- package/styles/design-tokens-export.js +554 -0
package/components/col/col.yaml
CHANGED
|
@@ -18,6 +18,11 @@ props:
|
|
|
18
18
|
or a numeric rung on the spacing scale ("1"…"16", mapped to --a-space-N).
|
|
19
19
|
type: string
|
|
20
20
|
default: md
|
|
21
|
+
grow:
|
|
22
|
+
description: Fills remaining space in a flex parent (e.g. inside a Row). CSS-only attribute via :scope[grow] in col.css.
|
|
23
|
+
type: boolean
|
|
24
|
+
default: false
|
|
25
|
+
reflect: true
|
|
21
26
|
justify:
|
|
22
27
|
description: Justify content
|
|
23
28
|
type: string
|
|
@@ -42,7 +42,20 @@
|
|
|
42
42
|
],
|
|
43
43
|
"unevaluatedProperties": false,
|
|
44
44
|
"x-adiaui": {
|
|
45
|
-
"anti_patterns": [
|
|
45
|
+
"anti_patterns": [
|
|
46
|
+
{
|
|
47
|
+
"description": "Wrapping a check-ui (or switch-ui / radio-ui / toggle-ui) in field-ui. The widget already carries its own visible [label] via the CSS attr() pattern; field-ui adds a redundant label row and the inline-mode `justify-self: end` rule for compact controls pushes the widget to the right edge of the row, breaking the expected \"checkbox-left, label-right\" consent-row layout.",
|
|
48
|
+
"right": "<check-ui label=\"I agree to the Terms of Service\"></check-ui>\n",
|
|
49
|
+
"rule": "Small self-labeling widgets MUST NOT be wrapped in field-ui.",
|
|
50
|
+
"wrong": "<field-ui inline>\n <check-ui label=\"I agree to the Terms of Service\"></check-ui>\n</field-ui>\n"
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"description": "Using field-ui[inline] around a switch-ui to build a settings row. The settings-row layout (label-left, control-right) IS the switch-ui's own [label] rendering — field-ui adds no value and breaks the layout via the `justify-self: end` rule for compact widgets.",
|
|
54
|
+
"right": "<switch-ui label=\"Email notifications\"></switch-ui>\n",
|
|
55
|
+
"rule": "Use the widget's own [label] attribute for settings + consent rows. field-ui is for wide-control rows only.",
|
|
56
|
+
"wrong": "<field-ui inline label=\"Email notifications\">\n <switch-ui></switch-ui>\n</field-ui>\n"
|
|
57
|
+
}
|
|
58
|
+
],
|
|
46
59
|
"category": "form",
|
|
47
60
|
"events": {},
|
|
48
61
|
"examples": [
|
|
@@ -69,14 +82,12 @@
|
|
|
69
82
|
"input",
|
|
70
83
|
"select",
|
|
71
84
|
"textarea",
|
|
72
|
-
"
|
|
73
|
-
"
|
|
74
|
-
"switch",
|
|
75
|
-
"slider"
|
|
85
|
+
"slider",
|
|
86
|
+
"range"
|
|
76
87
|
],
|
|
77
88
|
"slots": {
|
|
78
89
|
"default": {
|
|
79
|
-
"description": "The form control — input-ui, select-ui, textarea-ui,
|
|
90
|
+
"description": "The form control — a WIDE control like input-ui, select-ui, textarea-ui, slider-ui, range-ui, calendar-picker-ui, color-picker-ui, upload-ui, otp-input-ui. Auto-id'd for the label's [for] binding.\nDO NOT wrap small self-labeling widgets here. check-ui, switch-ui, radio-ui, toggle-ui all carry their own [label] attribute that renders inline next to the control — wrapping them in field-ui produces broken layouts (settings-row `justify-self: end` rule pushes the control to the trailing edge, away from the label that field-ui stamps; the widget's own label then renders again on the right, creating a doubled / right-justified affordance). See anti_patterns below for the canonical alternatives."
|
|
80
91
|
},
|
|
81
92
|
"action": {
|
|
82
93
|
"description": "Button adjacent to the control for inline actions (clear, reset, help popover)."
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
2
|
import '../../core/element.js';
|
|
3
3
|
import './field.js';
|
|
4
|
+
// Preload <input-ui> at module top so the error-mirror test can rely on
|
|
5
|
+
// UIFormElement being registered without a dynamic import. The dynamic
|
|
6
|
+
// `await import('../input/input.js')` inside the test body was a known
|
|
7
|
+
// flake source under the full-suite parallel transform pipeline; moving
|
|
8
|
+
// it to top-level removes that race entirely.
|
|
9
|
+
import '../input/input.js';
|
|
4
10
|
|
|
5
11
|
const tick = () => new Promise((r) => queueMicrotask(r));
|
|
6
12
|
|
|
@@ -116,8 +122,8 @@ describe('field-ui', () => {
|
|
|
116
122
|
// Per field.js error-mirror architecture: field-ui reads .error from
|
|
117
123
|
// the CHILD UIFormElement control (not from its own attribute). So
|
|
118
124
|
// the test sets [error] on <input-ui> (UIFormElement-extending) — not
|
|
119
|
-
// <input> (raw HTML, no .error getter).
|
|
120
|
-
|
|
125
|
+
// <input> (raw HTML, no .error getter). input-ui is preloaded at the
|
|
126
|
+
// top of this file to avoid a flake-prone dynamic import here.
|
|
121
127
|
const f = mount('<field-ui label="E" hint="hi"><input-ui error="Required"></input-ui></field-ui>');
|
|
122
128
|
await tick();
|
|
123
129
|
const hint = f.querySelector('[data-field-hint]');
|
|
@@ -53,9 +53,20 @@ props:
|
|
|
53
53
|
slots:
|
|
54
54
|
default:
|
|
55
55
|
description: >-
|
|
56
|
-
The form control — input-ui, select-ui,
|
|
57
|
-
|
|
58
|
-
|
|
56
|
+
The form control — a WIDE control like input-ui, select-ui,
|
|
57
|
+
textarea-ui, slider-ui, range-ui, calendar-picker-ui,
|
|
58
|
+
color-picker-ui, upload-ui, otp-input-ui. Auto-id'd for the
|
|
59
|
+
label's [for] binding.
|
|
60
|
+
|
|
61
|
+
DO NOT wrap small self-labeling widgets here. check-ui,
|
|
62
|
+
switch-ui, radio-ui, toggle-ui all carry their own [label]
|
|
63
|
+
attribute that renders inline next to the control — wrapping
|
|
64
|
+
them in field-ui produces broken layouts (settings-row
|
|
65
|
+
`justify-self: end` rule pushes the control to the trailing
|
|
66
|
+
edge, away from the label that field-ui stamps; the widget's
|
|
67
|
+
own label then renders again on the right, creating a doubled
|
|
68
|
+
/ right-justified affordance). See anti_patterns below for the
|
|
69
|
+
canonical alternatives.
|
|
59
70
|
trailing:
|
|
60
71
|
description: >-
|
|
61
72
|
Secondary text or badge aligned with the label in the stacked
|
|
@@ -93,8 +104,38 @@ tokens:
|
|
|
93
104
|
--field-error-size:
|
|
94
105
|
description: Error text size.
|
|
95
106
|
a2ui:
|
|
96
|
-
rules:
|
|
97
|
-
|
|
107
|
+
rules:
|
|
108
|
+
- "field-ui is for WIDE controls (input-ui, select-ui, textarea-ui, slider-ui, etc.) that need a separate label row. Small self-labeling widgets (check-ui, switch-ui, radio-ui, toggle-ui) carry their own [label] attribute and MUST NOT be wrapped in field-ui."
|
|
109
|
+
- "field-ui[inline] is for inline WIDE-control rows (e.g. search field with trailing kbd hint), NOT for compact-widget rows. For settings rows or consent rows, use the widget's own [label] attribute directly without a field-ui wrapper."
|
|
110
|
+
anti_patterns:
|
|
111
|
+
- description: >-
|
|
112
|
+
Wrapping a check-ui (or switch-ui / radio-ui / toggle-ui) in
|
|
113
|
+
field-ui. The widget already carries its own visible [label]
|
|
114
|
+
via the CSS attr() pattern; field-ui adds a redundant label row
|
|
115
|
+
and the inline-mode `justify-self: end` rule for compact
|
|
116
|
+
controls pushes the widget to the right edge of the row,
|
|
117
|
+
breaking the expected "checkbox-left, label-right" consent-row
|
|
118
|
+
layout.
|
|
119
|
+
wrong: |
|
|
120
|
+
<field-ui inline>
|
|
121
|
+
<check-ui label="I agree to the Terms of Service"></check-ui>
|
|
122
|
+
</field-ui>
|
|
123
|
+
right: |
|
|
124
|
+
<check-ui label="I agree to the Terms of Service"></check-ui>
|
|
125
|
+
rule: Small self-labeling widgets MUST NOT be wrapped in field-ui.
|
|
126
|
+
- description: >-
|
|
127
|
+
Using field-ui[inline] around a switch-ui to build a settings
|
|
128
|
+
row. The settings-row layout (label-left, control-right) IS the
|
|
129
|
+
switch-ui's own [label] rendering — field-ui adds no value and
|
|
130
|
+
breaks the layout via the `justify-self: end` rule for compact
|
|
131
|
+
widgets.
|
|
132
|
+
wrong: |
|
|
133
|
+
<field-ui inline label="Email notifications">
|
|
134
|
+
<switch-ui></switch-ui>
|
|
135
|
+
</field-ui>
|
|
136
|
+
right: |
|
|
137
|
+
<switch-ui label="Email notifications"></switch-ui>
|
|
138
|
+
rule: Use the widget's own [label] attribute for settings + consent rows. field-ui is for wide-control rows only.
|
|
98
139
|
examples:
|
|
99
140
|
- name: stacked-email-field
|
|
100
141
|
description: >-
|
|
@@ -146,7 +187,8 @@ related:
|
|
|
146
187
|
- input
|
|
147
188
|
- select
|
|
148
189
|
- textarea
|
|
149
|
-
- check
|
|
150
|
-
- radio
|
|
151
|
-
- switch
|
|
152
190
|
- slider
|
|
191
|
+
- range
|
|
192
|
+
# check, switch, radio, toggle removed from related — they are
|
|
193
|
+
# self-labeling widgets that should NOT be wrapped in field-ui.
|
|
194
|
+
# See anti_patterns above.
|
package/components/index.js
CHANGED
|
@@ -11,6 +11,7 @@ import '../core/data-stream.js';
|
|
|
11
11
|
|
|
12
12
|
export { UIIcon } from './icon/icon.js';
|
|
13
13
|
export { UIButton } from './button/button.js';
|
|
14
|
+
export { UILink } from './link/link.js';
|
|
14
15
|
export { UIInput } from './input/input.js';
|
|
15
16
|
export { UITextarea } from './textarea/textarea.js';
|
|
16
17
|
export { UICheck } from './check/check.js';
|
|
@@ -38,6 +38,11 @@
|
|
|
38
38
|
"type": "boolean",
|
|
39
39
|
"default": false
|
|
40
40
|
},
|
|
41
|
+
"autocomplete": {
|
|
42
|
+
"description": "Browser autofill behavior per HTML autocomplete spec. Routed via setAttribute to the host element. Common values: off, on, cc-number, cc-exp, cc-csc, cc-name, email, username, current-password, new-password, one-time-code, given-name, family-name, street-address, postal-code.",
|
|
43
|
+
"type": "string",
|
|
44
|
+
"default": ""
|
|
45
|
+
},
|
|
41
46
|
"component": {
|
|
42
47
|
"const": "Input"
|
|
43
48
|
},
|
|
@@ -51,6 +56,21 @@
|
|
|
51
56
|
"type": "string",
|
|
52
57
|
"default": ""
|
|
53
58
|
},
|
|
59
|
+
"inputmode": {
|
|
60
|
+
"description": "Mobile keyboard hint per HTML inputmode spec. Routed via setAttribute to the host element. Values: text, decimal, numeric, tel, search, email, url.",
|
|
61
|
+
"type": "string",
|
|
62
|
+
"enum": [
|
|
63
|
+
"text",
|
|
64
|
+
"decimal",
|
|
65
|
+
"numeric",
|
|
66
|
+
"tel",
|
|
67
|
+
"search",
|
|
68
|
+
"email",
|
|
69
|
+
"url",
|
|
70
|
+
"none"
|
|
71
|
+
],
|
|
72
|
+
"default": null
|
|
73
|
+
},
|
|
54
74
|
"label": {
|
|
55
75
|
"description": "Inline label rendered as a leading caption inside the input chrome, between any prefix and the value. Wires aria-labelledby on the editable surface. For stacked label / hint / error compositions, wrap with field-ui.",
|
|
56
76
|
"type": "string",
|
|
@@ -462,8 +462,8 @@ class UIInput extends UIFormElement {
|
|
|
462
462
|
if (next === this.valueAsNumber) return;
|
|
463
463
|
this.value = this.#format(next);
|
|
464
464
|
this.syncValue(this.value);
|
|
465
|
-
this.dispatchEvent(new
|
|
466
|
-
this.dispatchEvent(new
|
|
465
|
+
this.dispatchEvent(new CustomEvent('input', { bubbles: true, detail: { value: this.value } }));
|
|
466
|
+
this.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value: this.value } }));
|
|
467
467
|
}
|
|
468
468
|
|
|
469
469
|
// ── Event handlers ──
|
|
@@ -488,7 +488,7 @@ class UIInput extends UIFormElement {
|
|
|
488
488
|
this.value = text;
|
|
489
489
|
if (!this.#isNativePassword) this.#textEl.toggleAttribute('data-empty', !text);
|
|
490
490
|
this.syncValue(text);
|
|
491
|
-
this.dispatchEvent(new
|
|
491
|
+
this.dispatchEvent(new CustomEvent('input', { bubbles: true, detail: { value: this.value } }));
|
|
492
492
|
};
|
|
493
493
|
|
|
494
494
|
#onBeforeInput = (e) => {
|
|
@@ -600,7 +600,7 @@ class UIInput extends UIFormElement {
|
|
|
600
600
|
e.preventDefault();
|
|
601
601
|
// Commit normalized value before firing form events.
|
|
602
602
|
this.#commitOnBlur();
|
|
603
|
-
this.dispatchEvent(new
|
|
603
|
+
this.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value: this.value } }));
|
|
604
604
|
this.dispatchEvent(new Event('submit', { bubbles: true }));
|
|
605
605
|
return;
|
|
606
606
|
}
|
|
@@ -608,7 +608,7 @@ class UIInput extends UIFormElement {
|
|
|
608
608
|
}
|
|
609
609
|
if (e.key === 'Enter') {
|
|
610
610
|
e.preventDefault();
|
|
611
|
-
this.dispatchEvent(new
|
|
611
|
+
this.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value: this.value } }));
|
|
612
612
|
this.dispatchEvent(new Event('submit', { bubbles: true }));
|
|
613
613
|
}
|
|
614
614
|
};
|
|
@@ -631,7 +631,7 @@ class UIInput extends UIFormElement {
|
|
|
631
631
|
|
|
632
632
|
#onBlur = () => {
|
|
633
633
|
if (this.#isNumberMode) this.#commitOnBlur();
|
|
634
|
-
this.dispatchEvent(new
|
|
634
|
+
this.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value: this.value } }));
|
|
635
635
|
};
|
|
636
636
|
|
|
637
637
|
#commitOnBlur() {
|
|
@@ -650,7 +650,7 @@ class UIInput extends UIFormElement {
|
|
|
650
650
|
if (this.value !== stored) {
|
|
651
651
|
this.value = stored;
|
|
652
652
|
this.syncValue(stored);
|
|
653
|
-
this.dispatchEvent(new
|
|
653
|
+
this.dispatchEvent(new CustomEvent('input', { bubbles: true, detail: { value: this.value } }));
|
|
654
654
|
}
|
|
655
655
|
if (this.#textEl.textContent !== displayed) {
|
|
656
656
|
this.#textEl.textContent = displayed;
|
|
@@ -665,8 +665,8 @@ class UIInput extends UIFormElement {
|
|
|
665
665
|
this.syncValue(this.value);
|
|
666
666
|
this.#textEl.textContent = this.value;
|
|
667
667
|
this.#textEl.toggleAttribute('data-empty', !this.value);
|
|
668
|
-
this.dispatchEvent(new
|
|
669
|
-
this.dispatchEvent(new
|
|
668
|
+
this.dispatchEvent(new CustomEvent('input', { bubbles: true, detail: { value: this.value } }));
|
|
669
|
+
this.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value: this.value } }));
|
|
670
670
|
}
|
|
671
671
|
|
|
672
672
|
#onPaste = (e) => {
|
|
@@ -64,6 +64,21 @@ props:
|
|
|
64
64
|
description: Minimum character length for validation
|
|
65
65
|
type: number
|
|
66
66
|
default: null
|
|
67
|
+
inputmode:
|
|
68
|
+
description: >-
|
|
69
|
+
Mobile keyboard hint per HTML inputmode spec. Routed via setAttribute
|
|
70
|
+
to the host element. Values: text, decimal, numeric, tel, search, email, url.
|
|
71
|
+
type: string
|
|
72
|
+
default: null
|
|
73
|
+
enum: [text, decimal, numeric, tel, search, email, url, none]
|
|
74
|
+
autocomplete:
|
|
75
|
+
description: >-
|
|
76
|
+
Browser autofill behavior per HTML autocomplete spec. Routed via
|
|
77
|
+
setAttribute to the host element. Common values: off, on, cc-number,
|
|
78
|
+
cc-exp, cc-csc, cc-name, email, username, current-password, new-password,
|
|
79
|
+
one-time-code, given-name, family-name, street-address, postal-code.
|
|
80
|
+
type: string
|
|
81
|
+
default: ""
|
|
67
82
|
min:
|
|
68
83
|
description: Minimum numeric value. Applies when `type="number"`. Clamps + drives aria-valuemin
|
|
69
84
|
+ the [-] button's disabled state.
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://adiaui.dev/a2ui/v0_9/components/Link.json",
|
|
4
|
+
"title": "Link",
|
|
5
|
+
"description": "Inline navigation primitive — semantic `<a href>` wrapper. Use for\ncross-page navigation, footer / Terms-of-Service / Privacy-Policy\ninline references, \"Sign in\" / \"Sign up\" cross-page links, and any\naffordance whose purpose is to take the user somewhere (not to\nperform an action).\n\nSibling of `<button-ui>` — they have separate semantics and must\nnot be substituted for each other:\n\n| Affordance | Use |\n|---------------------------|----------------|\n| Submit form | `<button-ui>` |\n| Trigger action / modal | `<button-ui>` |\n| Copy to clipboard | `<button-ui>` |\n| Open modal / drawer | `<button-ui>` |\n| Navigate to another page | `<link-ui>` |\n| Open external URL | `<link-ui>` |\n| Anchor jump (#section) | `<link-ui>` |\n| Inline reference in prose | `<link-ui>` |\n\nRenders `<a href=\"…\">` internally so middle-click open-in-new-tab,\nright-click context menu, hover URL preview, search-engine\ncrawlability, and bookmark-ability all work without any custom\nwiring. ARIA role is \"link\" (set automatically by `<a>` element).\n",
|
|
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
|
+
"block": {
|
|
17
|
+
"description": "Stretches the link to fill its container; useful for standalone link rows.",
|
|
18
|
+
"type": "boolean",
|
|
19
|
+
"default": false
|
|
20
|
+
},
|
|
21
|
+
"component": {
|
|
22
|
+
"const": "Link"
|
|
23
|
+
},
|
|
24
|
+
"disabled": {
|
|
25
|
+
"description": "Suppresses navigation + applies muted styling. Sets aria-disabled.",
|
|
26
|
+
"type": "boolean",
|
|
27
|
+
"default": false
|
|
28
|
+
},
|
|
29
|
+
"href": {
|
|
30
|
+
"description": "Destination URL or anchor. Required for SEO / middle-click / hover preview semantics. If omitted, the link still dispatches the `press` event (so it can be wired through the A2UI action handler system via `handler: \"navigate\"`), but loses native link behaviors.",
|
|
31
|
+
"type": "string",
|
|
32
|
+
"default": ""
|
|
33
|
+
},
|
|
34
|
+
"icon": {
|
|
35
|
+
"description": "Optional leading icon (Phosphor name). Use sparingly — most inline links don't need an icon. For \"open in new tab\" affordance, the `target=\"_blank\"` attribute auto-renders a trailing arrow-up-right glyph; the `icon` prop is for leading semantic icons.",
|
|
36
|
+
"type": "string",
|
|
37
|
+
"default": ""
|
|
38
|
+
},
|
|
39
|
+
"rel": {
|
|
40
|
+
"description": "Explicit `rel` attribute. Defaults to `noopener noreferrer` when `target=\"_blank\"` is set without an explicit rel.",
|
|
41
|
+
"type": "string",
|
|
42
|
+
"default": ""
|
|
43
|
+
},
|
|
44
|
+
"target": {
|
|
45
|
+
"description": "Anchor target — same semantics as HTML `<a target>`. Use `_blank` to open in new tab; the implementation automatically adds `rel=\"noopener noreferrer\"` for `_blank` to prevent tab-napping / privacy leaks.",
|
|
46
|
+
"type": "string",
|
|
47
|
+
"enum": [
|
|
48
|
+
"",
|
|
49
|
+
"_self",
|
|
50
|
+
"_blank",
|
|
51
|
+
"_parent",
|
|
52
|
+
"_top"
|
|
53
|
+
],
|
|
54
|
+
"default": ""
|
|
55
|
+
},
|
|
56
|
+
"text": {
|
|
57
|
+
"description": "Visible link text. Falls back to default-slot content if unset.",
|
|
58
|
+
"type": "string",
|
|
59
|
+
"default": ""
|
|
60
|
+
},
|
|
61
|
+
"variant": {
|
|
62
|
+
"description": "Visual treatment. `default` underlines on rest + hover (standard link affordance). `subtle` underlines only on hover (for tighter designs where always-underlined would be noisy). `quiet` drops the link color and matches surrounding text color (used for footer-link rows where the link affordance is implied by context, not by color).",
|
|
63
|
+
"type": "string",
|
|
64
|
+
"enum": [
|
|
65
|
+
"default",
|
|
66
|
+
"subtle",
|
|
67
|
+
"quiet"
|
|
68
|
+
],
|
|
69
|
+
"default": "default"
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
"required": [
|
|
73
|
+
"component"
|
|
74
|
+
],
|
|
75
|
+
"unevaluatedProperties": false,
|
|
76
|
+
"x-adiaui": {
|
|
77
|
+
"anti_patterns": [
|
|
78
|
+
"❌ `<button-ui variant=\"link\">` — was removed. Migrate to `<link-ui>` if the affordance is navigation, or to `<button-ui variant=\"ghost\">` if the affordance is an action that wants understated styling.",
|
|
79
|
+
"❌ `<link-ui>` with no `href` AND no `press` handler — a link to nowhere is a bug. Either set `href` or wire a navigate action handler.",
|
|
80
|
+
"❌ `<link-ui>` for form submission — submission is a button concern. Use `<button-ui type=\"submit\">`."
|
|
81
|
+
],
|
|
82
|
+
"category": "content",
|
|
83
|
+
"events": {
|
|
84
|
+
"press": {
|
|
85
|
+
"description": "Bubbles when the link is activated by click or Enter. Detail: `{ href, target }`. Fires BEFORE the browser's native navigation so handlers can `preventDefault()` and route through the A2UI action handler system. If no handler intercepts, native navigation proceeds."
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
"examples": [
|
|
89
|
+
{
|
|
90
|
+
"title": "Inline link in a sentence",
|
|
91
|
+
"code": "<text-ui>\n I agree to the\n <link-ui text=\"Terms of Service\" href=\"/terms\"></link-ui>\n and\n <link-ui text=\"Privacy Policy\" href=\"/privacy\"></link-ui>.\n</text-ui>\n"
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
"title": "External link with new-tab target",
|
|
95
|
+
"code": "<link-ui text=\"Read the spec\" href=\"https://example.com/spec\" target=\"_blank\"></link-ui>\n"
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
"title": "Footer link row",
|
|
99
|
+
"code": "<row-ui justify=\"center\" gap=\"2\">\n <link-ui text=\"Already have an account?\" variant=\"quiet\" href=\"/signin\"></link-ui>\n <link-ui text=\"Sign in\" href=\"/signin\"></link-ui>\n</row-ui>\n"
|
|
100
|
+
}
|
|
101
|
+
],
|
|
102
|
+
"keywords": [
|
|
103
|
+
"link",
|
|
104
|
+
"anchor",
|
|
105
|
+
"navigation",
|
|
106
|
+
"hyperlink",
|
|
107
|
+
"href",
|
|
108
|
+
"navigate",
|
|
109
|
+
"route",
|
|
110
|
+
"url"
|
|
111
|
+
],
|
|
112
|
+
"name": "UILink",
|
|
113
|
+
"related": [
|
|
114
|
+
"Button",
|
|
115
|
+
"NavItem",
|
|
116
|
+
"Breadcrumb"
|
|
117
|
+
],
|
|
118
|
+
"slots": {
|
|
119
|
+
"default": {
|
|
120
|
+
"description": "Link text content when the `text` prop is unused."
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
"states": [
|
|
124
|
+
{
|
|
125
|
+
"description": "Default rest state — underlined (or per variant).",
|
|
126
|
+
"name": "idle"
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
"description": "Color shifts to `--a-link-hover`.",
|
|
130
|
+
"name": "hover"
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
"description": "Auto-styled via `:visited` pseudo when navigating to a previously-visited URL.",
|
|
134
|
+
"name": "visited"
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
"description": "Suppressed activation; muted text color; aria-disabled.",
|
|
138
|
+
"name": "disabled"
|
|
139
|
+
}
|
|
140
|
+
],
|
|
141
|
+
"synonyms": {
|
|
142
|
+
"Link": [
|
|
143
|
+
"Anchor",
|
|
144
|
+
"Hyperlink",
|
|
145
|
+
"NavLink"
|
|
146
|
+
]
|
|
147
|
+
},
|
|
148
|
+
"tag": "link-ui",
|
|
149
|
+
"tokens": {
|
|
150
|
+
"--link-color": {
|
|
151
|
+
"description": "Resting link color. Default `var(--a-link)`."
|
|
152
|
+
},
|
|
153
|
+
"--link-color-hover": {
|
|
154
|
+
"description": "Hover-state color. Default `var(--a-link-hover)`."
|
|
155
|
+
},
|
|
156
|
+
"--link-color-visited": {
|
|
157
|
+
"description": "Visited-state color. Default `var(--a-link-visited)`."
|
|
158
|
+
},
|
|
159
|
+
"--link-underline-offset": {
|
|
160
|
+
"description": "Distance between baseline and underline. Default `2px`."
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
"traits": [],
|
|
164
|
+
"version": 1
|
|
165
|
+
}
|
|
166
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/* <link-ui> — inline navigation primitive.
|
|
2
|
+
|
|
3
|
+
The custom element wraps a real <a> element. Style the anchor (not
|
|
4
|
+
the host) so the rendered hyperlink inherits the proper text-decoration
|
|
5
|
+
+ focus-ring behavior from the native element. The host is `display:
|
|
6
|
+
inline` so it flows in sentence context by default. */
|
|
7
|
+
|
|
8
|
+
@scope (link-ui) {
|
|
9
|
+
:where(:scope) {
|
|
10
|
+
--link-color: var(--a-link);
|
|
11
|
+
--link-color-hover: var(--a-link-hover);
|
|
12
|
+
--link-color-visited: var(--a-link-visited);
|
|
13
|
+
--link-underline-offset: 2px;
|
|
14
|
+
--link-focus-ring: var(--a-focus-ring);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
:scope {
|
|
18
|
+
display: inline;
|
|
19
|
+
color: var(--link-color);
|
|
20
|
+
/* The text-decoration is on the inner <a>, not the host, so that
|
|
21
|
+
host-level color overrides cascade correctly. */
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
:scope > a {
|
|
25
|
+
color: inherit;
|
|
26
|
+
text-decoration: underline;
|
|
27
|
+
text-underline-offset: var(--link-underline-offset);
|
|
28
|
+
cursor: pointer;
|
|
29
|
+
/* Standard transition for color hover. */
|
|
30
|
+
transition: color var(--a-duration-fast) var(--a-easing);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/* When the anchor contains an icon, present it inline with text. */
|
|
34
|
+
:scope > a:has(icon-ui) {
|
|
35
|
+
display: inline-flex;
|
|
36
|
+
align-items: baseline;
|
|
37
|
+
gap: var(--a-space-1);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
:scope > a:hover {
|
|
41
|
+
color: var(--link-color-hover);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
:scope > a:visited {
|
|
45
|
+
color: var(--link-color-visited);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/* Focus ring on the anchor (the actual focusable element). */
|
|
49
|
+
:scope > a:focus-visible {
|
|
50
|
+
outline: none;
|
|
51
|
+
box-shadow: var(--link-focus-ring);
|
|
52
|
+
border-radius: var(--a-radius-sm);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/* ── Variants ── */
|
|
56
|
+
|
|
57
|
+
/* `subtle` — no underline until hover. For tighter designs. */
|
|
58
|
+
:scope[variant="subtle"] > a {
|
|
59
|
+
text-decoration: none;
|
|
60
|
+
}
|
|
61
|
+
:scope[variant="subtle"] > a:hover,
|
|
62
|
+
:scope[variant="subtle"] > a:focus-visible {
|
|
63
|
+
text-decoration: underline;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/* `quiet` — drop link color; match surrounding text. The link
|
|
67
|
+
affordance is implied by hover behavior + cursor. For "Already
|
|
68
|
+
have an account?" type prose where the link role is contextual. */
|
|
69
|
+
:scope[variant="quiet"] {
|
|
70
|
+
--link-color: inherit;
|
|
71
|
+
--link-color-hover: var(--a-link-hover);
|
|
72
|
+
}
|
|
73
|
+
:scope[variant="quiet"] > a {
|
|
74
|
+
text-decoration: none;
|
|
75
|
+
}
|
|
76
|
+
:scope[variant="quiet"] > a:hover {
|
|
77
|
+
text-decoration: underline;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/* ── Layout modifiers ── */
|
|
81
|
+
|
|
82
|
+
:scope[block] {
|
|
83
|
+
display: block;
|
|
84
|
+
}
|
|
85
|
+
:scope[block] > a {
|
|
86
|
+
display: block;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/* ── Disabled ── */
|
|
90
|
+
|
|
91
|
+
:scope[disabled] > a {
|
|
92
|
+
color: var(--a-fg-disabled);
|
|
93
|
+
cursor: not-allowed;
|
|
94
|
+
text-decoration-color: var(--a-fg-disabled);
|
|
95
|
+
pointer-events: none;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/* `:scope:state` selectors inside @scope have known Safari-17 issues,
|
|
100
|
+
per the comment in button.css. None used here — :focus-visible and
|
|
101
|
+
:hover are on the inner <a>, not on :scope — so this component
|
|
102
|
+
is unaffected. */
|