@adia-ai/web-components 0.6.36 → 0.6.38

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.
Files changed (159) hide show
  1. package/CHANGELOG.md +48 -1
  2. package/components/accordion/accordion-item.a2ui.json +3 -0
  3. package/components/accordion/accordion-item.yaml +5 -0
  4. package/components/action-list/action-item.a2ui.json +5 -1
  5. package/components/action-list/action-item.yaml +7 -0
  6. package/components/badge/badge.a2ui.json +10 -0
  7. package/components/badge/badge.css +70 -0
  8. package/components/badge/badge.yaml +20 -0
  9. package/components/blockquote/blockquote.a2ui.json +121 -0
  10. package/components/blockquote/blockquote.class.js +68 -0
  11. package/components/blockquote/blockquote.css +46 -0
  12. package/components/blockquote/blockquote.d.ts +31 -0
  13. package/components/blockquote/blockquote.js +17 -0
  14. package/components/blockquote/blockquote.yaml +124 -0
  15. package/components/button/button.css +11 -3
  16. package/components/calendar-picker/calendar-picker.a2ui.json +15 -0
  17. package/components/calendar-picker/calendar-picker.class.js +7 -1
  18. package/components/calendar-picker/calendar-picker.yaml +14 -0
  19. package/components/card/card.a2ui.json +17 -1
  20. package/components/card/card.yaml +24 -1
  21. package/components/color-input/color-input.a2ui.json +2 -2
  22. package/components/color-input/color-input.class.js +9 -2
  23. package/components/color-input/color-input.yaml +2 -2
  24. package/components/combobox/combobox.class.js +4 -0
  25. package/components/context-menu/context-menu.a2ui.json +159 -0
  26. package/components/context-menu/context-menu.class.js +275 -0
  27. package/components/context-menu/context-menu.css +56 -0
  28. package/components/context-menu/context-menu.d.ts +70 -0
  29. package/components/context-menu/context-menu.js +17 -0
  30. package/components/context-menu/context-menu.yaml +136 -0
  31. package/components/date-range-picker/date-range-picker.a2ui.json +15 -0
  32. package/components/date-range-picker/date-range-picker.class.js +2 -0
  33. package/components/date-range-picker/date-range-picker.yaml +14 -0
  34. package/components/datetime-picker/datetime-picker.a2ui.json +15 -0
  35. package/components/datetime-picker/datetime-picker.class.js +3 -1
  36. package/components/datetime-picker/datetime-picker.d.ts +2 -0
  37. package/components/datetime-picker/datetime-picker.yaml +14 -0
  38. package/components/empty-state/empty-state.a2ui.json +9 -0
  39. package/components/empty-state/empty-state.class.js +2 -0
  40. package/components/empty-state/empty-state.yaml +15 -0
  41. package/components/feed/feed-item.a2ui.json +5 -0
  42. package/components/feed/feed-item.yaml +10 -0
  43. package/components/feed/feed.class.js +13 -5
  44. package/components/feed/feed.css +14 -0
  45. package/components/field/field.a2ui.json +6 -0
  46. package/components/field/field.yaml +10 -0
  47. package/components/index.js +11 -0
  48. package/components/inline-edit/inline-edit.a2ui.json +159 -0
  49. package/components/inline-edit/inline-edit.class.js +184 -0
  50. package/components/inline-edit/inline-edit.css +62 -0
  51. package/components/inline-edit/inline-edit.d.ts +52 -0
  52. package/components/inline-edit/inline-edit.js +12 -0
  53. package/components/inline-edit/inline-edit.yaml +125 -0
  54. package/components/integration-card/integration-card.class.js +9 -0
  55. package/components/integration-card/integration-card.test.js +4 -3
  56. package/components/list/list-item.a2ui.json +8 -1
  57. package/components/list/list-item.yaml +12 -0
  58. package/components/list/list.css +36 -6
  59. package/components/mark/mark.a2ui.json +109 -0
  60. package/components/mark/mark.class.js +22 -0
  61. package/components/mark/mark.css +39 -0
  62. package/components/mark/mark.d.ts +27 -0
  63. package/components/mark/mark.js +12 -0
  64. package/components/mark/mark.yaml +87 -0
  65. package/components/modal/modal.a2ui.json +9 -0
  66. package/components/modal/modal.yaml +14 -0
  67. package/components/nav-group/nav-group.a2ui.json +3 -0
  68. package/components/nav-group/nav-group.css +7 -1
  69. package/components/nav-group/nav-group.yaml +5 -0
  70. package/components/nav-item/nav-item.a2ui.json +3 -0
  71. package/components/nav-item/nav-item.yaml +5 -0
  72. package/components/number-format/number-format.a2ui.json +180 -0
  73. package/components/number-format/number-format.class.js +96 -0
  74. package/components/number-format/number-format.css +18 -0
  75. package/components/number-format/number-format.d.ts +68 -0
  76. package/components/number-format/number-format.js +17 -0
  77. package/components/number-format/number-format.yaml +204 -0
  78. package/components/pagination/pagination.a2ui.json +19 -2
  79. package/components/pagination/pagination.class.js +90 -37
  80. package/components/pagination/pagination.css +32 -127
  81. package/components/pagination/pagination.d.ts +8 -2
  82. package/components/pagination/pagination.test.js +195 -0
  83. package/components/pagination/pagination.yaml +22 -1
  84. package/components/password-strength/password-strength.a2ui.json +152 -0
  85. package/components/password-strength/password-strength.class.js +157 -0
  86. package/components/password-strength/password-strength.css +80 -0
  87. package/components/password-strength/password-strength.d.ts +59 -0
  88. package/components/password-strength/password-strength.js +17 -0
  89. package/components/password-strength/password-strength.yaml +153 -0
  90. package/components/popover/popover.css +43 -23
  91. package/components/popover/popover.yaml +8 -4
  92. package/components/qr-code/QR-TEST.svg +4 -0
  93. package/components/qr-code/qr-code.a2ui.json +154 -0
  94. package/components/qr-code/qr-code.class.js +129 -0
  95. package/components/qr-code/qr-code.css +41 -0
  96. package/components/qr-code/qr-code.d.ts +83 -0
  97. package/components/qr-code/qr-code.js +17 -0
  98. package/components/qr-code/qr-code.yaml +203 -0
  99. package/components/qr-code/qr-encoder.js +633 -0
  100. package/components/relative-time/relative-time.a2ui.json +120 -0
  101. package/components/relative-time/relative-time.class.js +136 -0
  102. package/components/relative-time/relative-time.css +22 -0
  103. package/components/relative-time/relative-time.d.ts +51 -0
  104. package/components/relative-time/relative-time.js +17 -0
  105. package/components/relative-time/relative-time.yaml +133 -0
  106. package/components/segmented/segmented.class.js +15 -3
  107. package/components/select/select.a2ui.json +3 -0
  108. package/components/select/select.class.js +4 -0
  109. package/components/select/select.yaml +5 -0
  110. package/components/skip-nav/skip-nav.a2ui.json +92 -0
  111. package/components/skip-nav/skip-nav.class.js +45 -0
  112. package/components/skip-nav/skip-nav.css +54 -0
  113. package/components/skip-nav/skip-nav.d.ts +27 -0
  114. package/components/skip-nav/skip-nav.js +12 -0
  115. package/components/skip-nav/skip-nav.yaml +68 -0
  116. package/components/slider/slider.a2ui.json +22 -1
  117. package/components/slider/slider.class.js +264 -122
  118. package/components/slider/slider.css +82 -2
  119. package/components/slider/slider.d.ts +19 -3
  120. package/components/slider/slider.test.js +55 -0
  121. package/components/slider/slider.yaml +38 -6
  122. package/components/stat/stat.css +18 -14
  123. package/components/stepper/stepper-item.a2ui.json +3 -0
  124. package/components/stepper/stepper-item.yaml +5 -0
  125. package/components/table/table.class.js +29 -6
  126. package/components/table/table.css +31 -4
  127. package/components/table-toolbar/table-toolbar.class.js +3 -1
  128. package/components/tag/tag.a2ui.json +3 -2
  129. package/components/tag/tag.css +35 -11
  130. package/components/tag/tag.d.ts +14 -0
  131. package/components/tag/tag.test.js +35 -11
  132. package/components/tag/tag.yaml +13 -7
  133. package/components/timeline/timeline-item.a2ui.json +8 -1
  134. package/components/timeline/timeline-item.yaml +12 -0
  135. package/components/toast/toast.class.js +12 -4
  136. package/components/toc/toc.a2ui.json +159 -0
  137. package/components/toc/toc.class.js +222 -0
  138. package/components/toc/toc.css +92 -0
  139. package/components/toc/toc.d.ts +61 -0
  140. package/components/toc/toc.js +17 -0
  141. package/components/toc/toc.yaml +180 -0
  142. package/components/toolbar/toolbar.class.js +3 -0
  143. package/components/tree/tree-item.a2ui.json +5 -1
  144. package/components/tree/tree-item.yaml +7 -0
  145. package/components/tree/tree.a2ui.json +3 -0
  146. package/components/tree/tree.yaml +5 -0
  147. package/components/visually-hidden/visually-hidden.a2ui.json +71 -0
  148. package/components/visually-hidden/visually-hidden.class.js +14 -0
  149. package/components/visually-hidden/visually-hidden.css +25 -0
  150. package/components/visually-hidden/visually-hidden.d.ts +26 -0
  151. package/components/visually-hidden/visually-hidden.js +12 -0
  152. package/components/visually-hidden/visually-hidden.yaml +54 -0
  153. package/core/anchor.js +19 -3
  154. package/dist/web-components.min.css +1 -1
  155. package/dist/web-components.min.js +100 -89
  156. package/package.json +1 -1
  157. package/styles/colors/semantics.css +11 -2
  158. package/styles/components.css +11 -0
  159. package/styles/resets.css +10 -0
@@ -161,15 +161,23 @@ button-ui[color="danger"]:not([disabled]):hover {
161
161
  --button-fg-default: var(--button-fg-danger, var(--button-fg-danger-default));
162
162
  }
163
163
  :scope[color="success"] {
164
- --button-bg-default: var(--a-success-strong);
164
+ /* Consume the L3 `-bg` alias, not the L2 `-strong` step. Currently
165
+ resolves identically (both = `-50`), but consuming `-bg` future-
166
+ proofs if the surface-step ever redirects (as -warning-bg did
167
+ in v0.6.36 to fix muddy contrast). */
168
+ --button-bg-default: var(--a-success-bg);
165
169
  --button-fg-default: var(--a-success-fg);
166
170
  }
167
171
  :scope[color="info"] {
168
- --button-bg-default: var(--a-info-strong);
172
+ --button-bg-default: var(--a-info-bg);
169
173
  --button-fg-default: var(--a-info-fg);
170
174
  }
171
175
  :scope[color="warning"] {
172
- --button-bg-default: var(--a-warning-strong);
176
+ /* `--a-warning-bg` (bright amber, scheme-independent) not
177
+ `-strong` (mid-tone). The pair `-strong` + `-fg` gives muddy
178
+ brown-on-brown — the L3 `-bg` is the canonical solid-warning
179
+ surface paired with `-fg` (dark text). */
180
+ --button-bg-default: var(--a-warning-bg);
173
181
  --button-fg-default: var(--a-warning-fg);
174
182
  }
175
183
 
@@ -66,6 +66,21 @@
66
66
  "type": "string",
67
67
  "default": "Select date..."
68
68
  },
69
+ "placement": {
70
+ "description": "Popover placement relative to the trigger. Default `bottom` centers the calendar panel under the trigger (ADR-0034 Rule 2 — calendar panel wider than trigger).",
71
+ "type": "string",
72
+ "enum": [
73
+ "top",
74
+ "bottom",
75
+ "left",
76
+ "right",
77
+ "top-start",
78
+ "top-end",
79
+ "bottom-start",
80
+ "bottom-end"
81
+ ],
82
+ "default": "bottom"
83
+ },
69
84
  "value": {
70
85
  "description": "Selected date (ISO string)",
71
86
  "type": "string",
@@ -68,6 +68,10 @@ export class UICalendarPicker extends UIFormElement {
68
68
  min: { type: String, default: '', reflect: true },
69
69
  max: { type: String, default: '', reflect: true },
70
70
  open: { type: Boolean, default: false, reflect: true },
71
+ // Popover placement — yaml documented this as reflect:true since v1;
72
+ // the JS reads it via getAttribute('placement') in the anchorPopover
73
+ // call site. Declaring here so el.placement = 'top' also updates.
74
+ placement: { type: String, default: 'bottom', reflect: true },
71
75
  };
72
76
 
73
77
  static template = () => null;
@@ -139,8 +143,10 @@ export class UICalendarPicker extends UIFormElement {
139
143
  if (this.open) {
140
144
  this.#renderCalendar();
141
145
  this.#popover?.showPopover?.();
146
+ // ADR-0034 Rule 2: calendar panel (~330px) >> trigger button (~200px) → center.
147
+ // Consumer can override via placement="bottom-start|top|…" attribute.
142
148
  this.#anchorCleanup = anchorPopover(this.#trigger, this.#popover, {
143
- placement: 'bottom-start', gap: 4,
149
+ placement: this.getAttribute('placement') || 'bottom', gap: 4,
144
150
  });
145
151
  this.#openRaf = requestAnimationFrame(() => {
146
152
  this.#openRaf = null;
@@ -59,6 +59,20 @@ props:
59
59
  description: Selected date (ISO string)
60
60
  type: string
61
61
  default: ''
62
+ placement:
63
+ description: Popover placement relative to the trigger. Default `bottom` centers the calendar panel under the trigger (ADR-0034 Rule 2 — calendar panel wider than trigger).
64
+ type: string
65
+ default: bottom
66
+ reflect: true
67
+ enum:
68
+ - top
69
+ - bottom
70
+ - left
71
+ - right
72
+ - top-start
73
+ - top-end
74
+ - bottom-start
75
+ - bottom-end
62
76
  events:
63
77
  change:
64
78
  description: Fired when a date is selected
@@ -113,7 +113,23 @@
113
113
  "alert",
114
114
  "skeleton"
115
115
  ],
116
- "slots": {},
116
+ "slots": {
117
+ "description": {
118
+ "description": "Optional descriptive text beneath the heading. Renders in the header slot at body-subtle typography. Use for short metadata lines (timestamp, author, status sentence)."
119
+ },
120
+ "action": {
121
+ "description": "Trailing action cluster in the header (e.g. icon-buttons, menu trigger, more-options). Aligns to the header's flex-end edge."
122
+ },
123
+ "action-leading": {
124
+ "description": "Leading action cluster in the header (e.g. back button, switcher, breadcrumb-context). Aligns to the header's flex-start edge, before the icon/heading column."
125
+ },
126
+ "heading": {
127
+ "description": "Card title. Renders in the header slot with title typography. Typically a short noun phrase or document/object name."
128
+ },
129
+ "icon": {
130
+ "description": "Optional leading icon for the card header (status / brand / type marker). Renders next to the heading. Use `<icon-ui name=\"…\">` or any inline icon element."
131
+ }
132
+ },
117
133
  "states": [
118
134
  {
119
135
  "description": "Default, ready for interaction.",
@@ -58,7 +58,30 @@ props:
58
58
  - soft
59
59
  - primary
60
60
  events: {}
61
- slots: {}
61
+ slots:
62
+ icon:
63
+ description: >-
64
+ Optional leading icon for the card header (status / brand / type
65
+ marker). Renders next to the heading. Use `<icon-ui name="…">` or
66
+ any inline icon element.
67
+ heading:
68
+ description: >-
69
+ Card title. Renders in the header slot with title typography.
70
+ Typically a short noun phrase or document/object name.
71
+ description:
72
+ description: >-
73
+ Optional descriptive text beneath the heading. Renders in the
74
+ header slot at body-subtle typography. Use for short metadata
75
+ lines (timestamp, author, status sentence).
76
+ action:
77
+ description: >-
78
+ Trailing action cluster in the header (e.g. icon-buttons, menu
79
+ trigger, more-options). Aligns to the header's flex-end edge.
80
+ action-leading:
81
+ description: >-
82
+ Leading action cluster in the header (e.g. back button, switcher,
83
+ breadcrumb-context). Aligns to the header's flex-start edge,
84
+ before the icon/heading column.
62
85
  states:
63
86
  - name: idle
64
87
  description: Default, ready for interaction.
@@ -66,7 +66,7 @@
66
66
  "default": false
67
67
  },
68
68
  "placement": {
69
- "description": "Popover placement relative to the trigger.",
69
+ "description": "Popover placement relative to the trigger. Default `bottom` centers the color-picker panel under the swatch button (ADR-0034 Rule 2 — panel wider than trigger).",
70
70
  "type": "string",
71
71
  "enum": [
72
72
  "top",
@@ -78,7 +78,7 @@
78
78
  "bottom-start",
79
79
  "bottom-end"
80
80
  ],
81
- "default": "bottom-start"
81
+ "default": "bottom"
82
82
  },
83
83
  "value": {
84
84
  "description": "Current color as a string in the active [format].",
@@ -42,7 +42,7 @@ export class UIColorInput extends UIFormElement {
42
42
  ...UIFormElement.properties,
43
43
  value: { type: String, default: '#3b82f6', reflect: true },
44
44
  format: { type: String, default: 'hex', reflect: true },
45
- placement: { type: String, default: 'bottom-start', reflect: true },
45
+ placement: { type: String, default: 'bottom', reflect: true }, // ADR-0034 Rule 2: color picker panel >> swatch trigger
46
46
  open: { type: Boolean, default: false, reflect: true },
47
47
  // Generation-constraint props forwarded to the inner <color-picker-ui>.
48
48
  // v0.5.13 §-TBD (FB-33 §1) — full parity with color-picker's 5 constraint
@@ -63,6 +63,7 @@ export class UIColorInput extends UIFormElement {
63
63
  #swatch = null;
64
64
  #valueLabel = null;
65
65
  #wired = false;
66
+ #popoverObserver = null;
66
67
 
67
68
  connected() {
68
69
  this.#mount();
@@ -165,7 +166,11 @@ export class UIColorInput extends UIFormElement {
165
166
  if (this.open !== open) this.open = open;
166
167
  };
167
168
  this.#popover.addEventListener('toggle', sync);
168
- new MutationObserver(sync).observe(this.#popover, {
169
+ // Store the observer in a #field so disconnected() can clean it up
170
+ // — without the explicit reference, the observer survives the host
171
+ // element across mount/unmount cycles (audit-lifecycle-leak).
172
+ this.#popoverObserver = new MutationObserver(sync);
173
+ this.#popoverObserver.observe(this.#popover, {
169
174
  attributes: true,
170
175
  attributeFilter: ['open'],
171
176
  });
@@ -216,6 +221,8 @@ export class UIColorInput extends UIFormElement {
216
221
  disconnected() {
217
222
  this.#picker?.removeEventListener('change', this.#onPickerChange);
218
223
  this.#picker?.removeEventListener('input', this.#onPickerInput);
224
+ this.#popoverObserver?.disconnect();
225
+ this.#popoverObserver = null;
219
226
  this.#wired = false;
220
227
  }
221
228
  }
@@ -44,9 +44,9 @@ props:
44
44
  default: false
45
45
  reflect: true
46
46
  placement:
47
- description: Popover placement relative to the trigger.
47
+ description: Popover placement relative to the trigger. Default `bottom` centers the color-picker panel under the swatch button (ADR-0034 Rule 2 — panel wider than trigger).
48
48
  type: string
49
- default: bottom-start
49
+ default: bottom
50
50
  reflect: true
51
51
  enum:
52
52
  - top
@@ -63,6 +63,10 @@ export class UICombobox extends UIFormElement {
63
63
  ...UIFormElement.properties,
64
64
  placeholder: { type: String, default: 'Select...', reflect: true },
65
65
  label: { type: String, default: '', reflect: true },
66
+ // Universal [size] system — sm/md/lg → 24/30/36 px (with density).
67
+ // yaml documented this as reflect:true since v1; closing the
68
+ // static-properties gap so el.size = 'lg' actually updates.
69
+ size: { type: String, default: 'md', reflect: true },
66
70
  open: { type: Boolean, default: false, reflect: true },
67
71
  freeText: { type: Boolean, default: false, reflect: true, attribute: 'free-text' },
68
72
  creatable: { type: Boolean, default: false, reflect: true },
@@ -0,0 +1,159 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://adiaui.dev/a2ui/v0_9/components/ContextMenu.json",
4
+ "title": "ContextMenu",
5
+ "description": "Right-click activated menu — the OS-native context-menu pattern as a\nweb component. Distinct from `menu-ui` (which is button-triggered):\nsame item shape (`menu-item-ui` children), different trigger surface\n(`contextmenu` event), and pointer-anchored positioning instead of\nelement-anchored. Pattern: WAI-APG Menu.\n\nTwo binding modes:\n **A. Wrap.** Default-slot child becomes the target:\n `<context-menu-ui><my-table>...</my-table>...items</context-menu-ui>`.\n **B. Selector.** Point at one or more existing elements via [for]:\n `<context-menu-ui for=\"#my-table\">...items</context-menu-ui>`.\n\nOn `contextmenu` event on a target: `preventDefault()`, position the\nmenu at the pointer coords, show via Popover API. Touch long-press\n(configurable via [long-press-ms]) does the same. Shift+F10 / Menu\nkey opens at the focused target's center for keyboard users.\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
+ "component": {
17
+ "const": "ContextMenu"
18
+ },
19
+ "for": {
20
+ "description": "CSS selector(s) for target element(s). Empty = use default-slot child.",
21
+ "type": "string",
22
+ "default": ""
23
+ },
24
+ "long-press-ms": {
25
+ "description": "Long-press duration (ms) on touch devices to open the menu.",
26
+ "type": "number",
27
+ "default": 500
28
+ },
29
+ "open": {
30
+ "description": "Programmatic open state. Set true to open at target center.",
31
+ "type": "boolean",
32
+ "default": false
33
+ }
34
+ },
35
+ "required": [
36
+ "component"
37
+ ],
38
+ "unevaluatedProperties": false,
39
+ "x-adiaui": {
40
+ "anti_patterns": [
41
+ {
42
+ "fix": "Wrap a target: `<context-menu-ui><my-target></my-target>...items</context-menu-ui>` OR point at one: `<context-menu-ui for=\"#my-target\">...items</context-menu-ui>`.",
43
+ "why": "No target binding — the menu never opens.",
44
+ "wrong": "<context-menu-ui>...just items...</context-menu-ui>"
45
+ }
46
+ ],
47
+ "category": "container",
48
+ "composes": [
49
+ "menu-item-ui"
50
+ ],
51
+ "events": {
52
+ "context-menu-close": {
53
+ "description": "Fired when the menu closes (item-select / outside-click / Escape).",
54
+ "detail": {
55
+ "reason": {
56
+ "description": "\"select\" | \"outside\" | \"escape\"",
57
+ "type": "string"
58
+ }
59
+ }
60
+ },
61
+ "context-menu-open": {
62
+ "description": "Fired when the menu opens (right-click / long-press / keyboard).",
63
+ "detail": {
64
+ "target": {
65
+ "description": "The target element the menu was opened on.",
66
+ "type": "Element"
67
+ },
68
+ "x": {
69
+ "description": "Pointer x coord (viewport-relative); null for keyboard activation.",
70
+ "type": "number"
71
+ },
72
+ "y": {
73
+ "description": "Pointer y coord; null for keyboard activation.",
74
+ "type": "number"
75
+ }
76
+ }
77
+ },
78
+ "context-menu-select": {
79
+ "description": "Fired when an item is activated. Same shape as menu-ui's `action` event.",
80
+ "detail": {
81
+ "text": {
82
+ "description": "Selected item's text.",
83
+ "type": "string"
84
+ },
85
+ "value": {
86
+ "description": "Selected item's value.",
87
+ "type": "string"
88
+ }
89
+ }
90
+ }
91
+ },
92
+ "examples": [
93
+ {
94
+ "description": "Right-click a file row for Open / Rename / Delete.",
95
+ "a2ui": "[\n { \"id\": \"root\", \"component\": \"ContextMenu\", \"children\": [\"target\", \"item-open\", \"item-rename\", \"div\", \"item-delete\"] },\n { \"id\": \"target\", \"component\": \"Text\", \"textContent\": \"Right-click me\" },\n { \"id\": \"item-open\", \"component\": \"MenuItem\", \"value\": \"open\", \"text\": \"Open\" },\n { \"id\": \"item-rename\", \"component\": \"MenuItem\", \"value\": \"rename\", \"text\": \"Rename\" },\n { \"id\": \"div\", \"component\": \"MenuDivider\" },\n { \"id\": \"item-delete\", \"component\": \"MenuItem\", \"value\": \"delete\", \"text\": \"Delete\", \"variant\": \"danger\" }\n]\n",
96
+ "name": "file-actions"
97
+ }
98
+ ],
99
+ "keywords": [
100
+ "context-menu",
101
+ "right-click",
102
+ "menu",
103
+ "popup-menu"
104
+ ],
105
+ "name": "UIContextMenu",
106
+ "related": [
107
+ "menu",
108
+ "menu-item",
109
+ "popover"
110
+ ],
111
+ "slots": {
112
+ "default": {
113
+ "description": "Two-purpose slot: the wrapped target element (mode A — first\nnon-menu-item-ui child) AND the menu-item-ui items. Items are\npromoted to the popover surface on open.\n"
114
+ }
115
+ },
116
+ "states": [
117
+ {
118
+ "description": "Default. Menu closed; trigger listeners attached.",
119
+ "name": "idle"
120
+ },
121
+ {
122
+ "description": "Menu visible at pointer position; focus inside.",
123
+ "attribute": "open",
124
+ "name": "open"
125
+ }
126
+ ],
127
+ "status": "stable",
128
+ "synonyms": {
129
+ "popup-menu": [
130
+ "menu",
131
+ "context-menu"
132
+ ],
133
+ "right-click": [
134
+ "context-menu"
135
+ ]
136
+ },
137
+ "tag": "context-menu-ui",
138
+ "tokens": {
139
+ "--context-menu-bg": {
140
+ "description": "Menu surface background color.",
141
+ "default": "var(--a-bg-subtle)"
142
+ },
143
+ "--context-menu-border": {
144
+ "description": "Menu surface border color.",
145
+ "default": "var(--a-border-subtle)"
146
+ },
147
+ "--context-menu-radius": {
148
+ "description": "Menu surface border radius.",
149
+ "default": "var(--a-radius-md)"
150
+ },
151
+ "--context-menu-shadow": {
152
+ "description": "Menu surface shadow.",
153
+ "default": "var(--a-shadow-lg)"
154
+ }
155
+ },
156
+ "traits": [],
157
+ "version": 1
158
+ }
159
+ }
@@ -0,0 +1,275 @@
1
+ /**
2
+ * `<context-menu-ui>` — right-click activated menu (OS context-menu pattern).
3
+ *
4
+ * Two binding shapes:
5
+ * A. Wrap a target — first non-menu-item-ui default-slot child becomes
6
+ * the contextmenu host.
7
+ * B. Point at targets via [for="<selector>"] — useful for whole-table
8
+ * / whole-canvas menus where wrapping isn't practical.
9
+ *
10
+ * Architecture: reuses the existing anchor pattern (core/anchor.js) by
11
+ * creating a virtual 1×1 anchor element at the pointer coords on each
12
+ * open. anchorPopover() does the rest — handles native CSS anchor-
13
+ * positioning + JS fallback + viewport clamping + scroll/resize updates.
14
+ * This keeps coord-anchored popovers using the same code path as every
15
+ * other popover surface in the system (menu-ui, popover-ui, etc.).
16
+ *
17
+ * @see ../../core/anchor.js
18
+ * @see ../menu/menu.class.js — peer button-triggered menu primitive.
19
+ */
20
+
21
+ import { UIElement } from '../../core/element.js';
22
+ import { anchorPopover } from '../../core/anchor.js';
23
+
24
+ const LONG_PRESS_DEFAULT = 500;
25
+
26
+ export class UIContextMenu extends UIElement {
27
+ static properties = {
28
+ for: { type: String, default: '', reflect: true },
29
+ open: { type: Boolean, default: false, reflect: true },
30
+ longPressMs: { type: Number, default: LONG_PRESS_DEFAULT, reflect: false, attribute: 'long-press-ms' },
31
+ };
32
+
33
+ static template = () => null;
34
+
35
+ #surface = null;
36
+ #virtualAnchor = null;
37
+ #anchorCleanup = null;
38
+ #targets = [];
39
+ #touchTimer = null;
40
+ #lastTarget = null;
41
+ #outsideHandler = null;
42
+ #keyHandler = null;
43
+
44
+ connected() {
45
+ super.connected();
46
+ this.#bindTargets();
47
+ }
48
+
49
+ disconnected() {
50
+ super.disconnected();
51
+ this.#unbindTargets();
52
+ this.#teardown();
53
+ this.#surface?.remove();
54
+ this.#surface = null;
55
+ }
56
+
57
+ /* ── Public API ──────────────────────────────────────────────────── */
58
+
59
+ openAt(x, y, target = null) {
60
+ target ??= this.#targets[0] ?? null;
61
+ if (!target) return;
62
+ this.#lastTarget = target;
63
+ if (x == null || y == null) {
64
+ const r = target.getBoundingClientRect();
65
+ x = r.left + r.width / 2;
66
+ y = r.top + r.height / 2;
67
+ }
68
+ this.#show(x, y);
69
+ }
70
+
71
+ close(reason = 'manual') {
72
+ this.#hide(reason);
73
+ }
74
+
75
+ /* ── Target binding ──────────────────────────────────────────────── */
76
+
77
+ #bindTargets() {
78
+ this.#targets = this.#resolveTargets();
79
+ for (const t of this.#targets) {
80
+ t.addEventListener('contextmenu', this.#onContextMenu);
81
+ t.addEventListener('touchstart', this.#onTouchStart, { passive: true });
82
+ t.addEventListener('touchend', this.#onTouchEnd);
83
+ t.addEventListener('touchcancel', this.#onTouchEnd);
84
+ t.addEventListener('keydown', this.#onTargetKeydown);
85
+ }
86
+ }
87
+
88
+ #unbindTargets() {
89
+ for (const t of this.#targets) {
90
+ t.removeEventListener('contextmenu', this.#onContextMenu);
91
+ t.removeEventListener('touchstart', this.#onTouchStart);
92
+ t.removeEventListener('touchend', this.#onTouchEnd);
93
+ t.removeEventListener('touchcancel', this.#onTouchEnd);
94
+ t.removeEventListener('keydown', this.#onTargetKeydown);
95
+ }
96
+ this.#targets = [];
97
+ }
98
+
99
+ #resolveTargets() {
100
+ if (this.for) {
101
+ try { return [...document.querySelectorAll(this.for)]; }
102
+ catch (e) {
103
+ // eslint-disable-next-line no-console
104
+ console.warn(`[context-menu-ui] invalid [for] selector: ${this.for}`, e);
105
+ return [];
106
+ }
107
+ }
108
+ for (const child of this.children) {
109
+ const tag = child.tagName.toLowerCase();
110
+ if (tag !== 'menu-item-ui' && tag !== 'menu-divider-ui') return [child];
111
+ }
112
+ return [];
113
+ }
114
+
115
+ /* ── Trigger event handlers ──────────────────────────────────────── */
116
+
117
+ #onContextMenu = (e) => {
118
+ if (e.defaultPrevented) return;
119
+ if (!this.#hasItems()) return;
120
+ e.preventDefault();
121
+ this.#lastTarget = e.currentTarget;
122
+ this.#show(e.clientX, e.clientY);
123
+ };
124
+
125
+ #onTouchStart = (e) => {
126
+ if (!this.#hasItems()) return;
127
+ const touch = e.touches[0];
128
+ if (!touch) return;
129
+ this.#touchTimer = setTimeout(() => {
130
+ this.#lastTarget = e.currentTarget;
131
+ this.#show(touch.clientX, touch.clientY);
132
+ }, this.longPressMs ?? LONG_PRESS_DEFAULT);
133
+ };
134
+
135
+ #onTouchEnd = () => {
136
+ if (this.#touchTimer) clearTimeout(this.#touchTimer);
137
+ this.#touchTimer = null;
138
+ };
139
+
140
+ #onTargetKeydown = (e) => {
141
+ if ((e.shiftKey && e.key === 'F10') || e.key === 'ContextMenu') {
142
+ if (!this.#hasItems()) return;
143
+ e.preventDefault();
144
+ this.#lastTarget = e.currentTarget;
145
+ this.openAt(null, null, e.currentTarget);
146
+ }
147
+ };
148
+
149
+ /* ── Show / hide ─────────────────────────────────────────────────── */
150
+
151
+ #hasItems() {
152
+ return [...this.children].some((c) => c.tagName.toLowerCase() === 'menu-item-ui');
153
+ }
154
+
155
+ #ensureSurface() {
156
+ if (this.#surface) return this.#surface;
157
+ const surface = document.createElement('div');
158
+ surface.setAttribute('data-context-menu-surface', '');
159
+ surface.setAttribute('popover', 'manual');
160
+ surface.setAttribute('role', 'menu');
161
+ surface.setAttribute('aria-label', this.getAttribute('aria-label') || 'Context menu');
162
+ surface.tabIndex = -1;
163
+ // Inline outline reset — the surface is appended to document.body
164
+ // (outside the @scope(context-menu-ui) boundary), so a scoped CSS
165
+ // rule wouldn't match. UA paints a 3-px default focus ring on the
166
+ // tabindex=-1 surface when it receives programmatic focus on open.
167
+ surface.style.outline = 'none';
168
+ surface.addEventListener('click', this.#onSurfaceClick);
169
+ document.body.appendChild(surface);
170
+ this.#surface = surface;
171
+ return surface;
172
+ }
173
+
174
+ #ensureVirtualAnchor(x, y) {
175
+ if (!this.#virtualAnchor) {
176
+ const anchor = document.createElement('div');
177
+ anchor.setAttribute('data-context-menu-anchor', '');
178
+ anchor.style.cssText = 'position:fixed;width:1px;height:1px;pointer-events:none;opacity:0;';
179
+ document.body.appendChild(anchor);
180
+ this.#virtualAnchor = anchor;
181
+ }
182
+ this.#virtualAnchor.style.left = `${x}px`;
183
+ this.#virtualAnchor.style.top = `${y}px`;
184
+ return this.#virtualAnchor;
185
+ }
186
+
187
+ #show(x, y) {
188
+ const surface = this.#ensureSurface();
189
+ // Hoist items into the surface.
190
+ surface.replaceChildren();
191
+ for (const child of [...this.children]) {
192
+ const tag = child.tagName.toLowerCase();
193
+ if (tag === 'menu-item-ui' || tag === 'menu-divider-ui') {
194
+ surface.appendChild(child.cloneNode(true));
195
+ }
196
+ }
197
+ // Promote to top layer FIRST so anchorPopover can measure its size.
198
+ try { surface.showPopover(); } catch { /* popover API unavailable */ }
199
+
200
+ // Reuse the canonical anchor pattern — virtual 1×1 element at pointer
201
+ // coords, then anchorPopover() handles native CSS anchor-positioning
202
+ // (with position-try-fallbacks for viewport flips) + JS fallback.
203
+ const anchor = this.#ensureVirtualAnchor(x, y);
204
+ this.#anchorCleanup?.();
205
+ this.#anchorCleanup = anchorPopover(anchor, surface, { placement: 'bottom-start', gap: 0 });
206
+
207
+ this.open = true;
208
+ this.#setupOpenHandlers();
209
+ const first = surface.querySelector('menu-item-ui:not([disabled])');
210
+ queueMicrotask(() => first?.focus?.());
211
+
212
+ this.dispatchEvent(new CustomEvent('context-menu-open', {
213
+ bubbles: true,
214
+ detail: { target: this.#lastTarget, x, y },
215
+ }));
216
+ }
217
+
218
+ #hide(reason = 'manual') {
219
+ if (!this.open) return;
220
+ this.open = false;
221
+ this.#teardown();
222
+ this.dispatchEvent(new CustomEvent('context-menu-close', {
223
+ bubbles: true,
224
+ detail: { reason },
225
+ }));
226
+ try { this.#lastTarget?.focus?.(); } catch { /* noop */ }
227
+ }
228
+
229
+ #teardown() {
230
+ this.#teardownOpenHandlers();
231
+ this.#anchorCleanup?.();
232
+ this.#anchorCleanup = null;
233
+ try { this.#surface?.hidePopover(); } catch { /* noop */ }
234
+ if (this.#virtualAnchor) {
235
+ this.#virtualAnchor.remove();
236
+ this.#virtualAnchor = null;
237
+ }
238
+ }
239
+
240
+ /* ── While-open document handlers ────────────────────────────────── */
241
+
242
+ #setupOpenHandlers() {
243
+ this.#outsideHandler = (e) => {
244
+ if (!this.#surface?.contains(e.target)) this.#hide('outside');
245
+ };
246
+ this.#keyHandler = (e) => {
247
+ if (e.key === 'Escape') { e.stopPropagation(); this.#hide('escape'); }
248
+ };
249
+ requestAnimationFrame(() => {
250
+ document.addEventListener('pointerdown', this.#outsideHandler);
251
+ document.addEventListener('keydown', this.#keyHandler);
252
+ });
253
+ }
254
+
255
+ #teardownOpenHandlers() {
256
+ if (this.#outsideHandler) document.removeEventListener('pointerdown', this.#outsideHandler);
257
+ if (this.#keyHandler) document.removeEventListener('keydown', this.#keyHandler);
258
+ this.#outsideHandler = null;
259
+ this.#keyHandler = null;
260
+ }
261
+
262
+ /* ── Item activation ─────────────────────────────────────────────── */
263
+
264
+ #onSurfaceClick = (e) => {
265
+ const item = e.target.closest('menu-item-ui');
266
+ if (!item || item.hasAttribute('disabled')) return;
267
+ const value = item.getAttribute('value') || '';
268
+ const text = item.getAttribute('text') || item.textContent.trim();
269
+ this.dispatchEvent(new CustomEvent('context-menu-select', {
270
+ bubbles: true,
271
+ detail: { value, text },
272
+ }));
273
+ this.#hide('select');
274
+ };
275
+ }