@antadesign/anta 0.3.10 → 0.3.11

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.
@@ -55,7 +55,10 @@ export type ContentMode = {
55
55
  iconTrailing?: IconShape;
56
56
  };
57
57
  /** Submit axis — anchors (href) don't carry form-submission props; buttons
58
- * don't carry anchor props. */
58
+ * don't carry anchor props. A link (`href`) also can't be a copy control, so the
59
+ * copy props live only on the non-link branch (via `CopyMode`) and are `never`
60
+ * on the link branch — a `<ButtonCopy href=…>` or `<Button href=… copy=…>` is a
61
+ * type error, not a silently-ignored attribute. */
59
62
  export type SubmitMode = {
60
63
  /** Renders as `<a role="button">` instead of `<a-button>`. */
61
64
  href: string;
@@ -69,7 +72,13 @@ export type SubmitMode = {
69
72
  ping?: string;
70
73
  type?: never;
71
74
  form?: never;
72
- } | {
75
+ copy?: never;
76
+ copyNode?: never;
77
+ copyUrl?: never;
78
+ copyWithUrl?: never;
79
+ onCopyRequest?: never;
80
+ onCopied?: never;
81
+ } | ({
73
82
  href?: never;
74
83
  target?: never;
75
84
  rel?: never;
@@ -79,7 +88,7 @@ export type SubmitMode = {
79
88
  type?: 'button' | 'submit' | 'reset';
80
89
  /** Form id when the button isn't a descendant of its form. */
81
90
  form?: string;
82
- };
91
+ } & CopyMode);
83
92
  /** Priority axis — `underline` only on `tertiary` / `quaternary`,
84
93
  * `paddingless` only on `quaternary`. */
85
94
  export type PriorityMode = {
@@ -102,21 +111,29 @@ export type PriorityMode = {
102
111
  };
103
112
  /** Copy axis — turns the button into a copy control that writes to the clipboard
104
113
  * on click and flashes a success / failure state (see `<a-button>`'s copy
105
- * behavior). **Exactly one** of `copy`, `copyNode`, or `copyLazy` may be set;
114
+ * behavior). **Exactly one** of `copy`, `copyNode`, or `copyUrl` may be set;
106
115
  * the union makes the others `never` in each mode. For the batteries-included
107
116
  * preset, use `ButtonCopy`. */
108
117
  export type CopyMode = {
109
118
  copy?: never;
110
119
  copyNode?: never;
111
- copyLazy?: never;
120
+ copyUrl?: never;
121
+ copyWithUrl?: never;
112
122
  onCopied?: never;
113
123
  onCopyRequest?: never;
114
124
  } | {
115
125
  /** Text copied to the clipboard on click. */
116
126
  copy: string;
127
+ /** Prefix the copied text with `// URL: <current page URL>`. */
128
+ copyWithUrl?: boolean;
129
+ /** Compute the copy content lazily. Fires on **pointerdown**; update `copy`
130
+ * (a state change) here and the click copies the latest value. The
131
+ * pointerdown→click gap lets the update land even when this handler runs
132
+ * off the UI thread (a worker-rendered app) — only the serializable `copy`
133
+ * string crosses, via the normal re-render. */
134
+ onCopyRequest?: () => void;
117
135
  copyNode?: never;
118
- copyLazy?: never;
119
- onCopyRequest?: never;
136
+ copyUrl?: never;
120
137
  /** Fires after the copy attempt with whether it succeeded. */
121
138
  onCopied?: (ok: boolean) => void;
122
139
  } | {
@@ -126,24 +143,22 @@ export type CopyMode = {
126
143
  * the copied output. */
127
144
  copyNode: boolean | string;
128
145
  copy?: never;
129
- copyLazy?: never;
146
+ copyUrl?: never;
147
+ copyWithUrl?: never;
130
148
  onCopyRequest?: never;
131
149
  /** Fires after the copy attempt with whether it succeeded. */
132
150
  onCopied?: (ok: boolean) => void;
133
151
  } | {
134
- /** Lazy copy: the content isn't computed or held in the DOM until the
135
- * click. The click fires `onCopyRequest(provide)`; call `provide(text)`
136
- * with the value — synchronously, or after an `await` (the browser's
137
- * transient-activation window still covers the write). */
138
- copyLazy: true;
152
+ /** Copy the current page URL (`location.href`). */
153
+ copyUrl: true;
139
154
  copy?: never;
140
155
  copyNode?: never;
141
- /** Supplies the lazily-computed content on click: call `provide(text)`. */
142
- onCopyRequest: (provide: (text: string) => void) => void;
156
+ copyWithUrl?: never;
157
+ onCopyRequest?: never;
143
158
  /** Fires after the copy attempt with whether it succeeded. */
144
159
  onCopied?: (ok: boolean) => void;
145
160
  };
146
- export type ButtonProps = BaseButtonProps & PriorityMode & ContentMode & SubmitMode & CopyMode & BaseProps;
161
+ export type ButtonProps = BaseButtonProps & PriorityMode & ContentMode & SubmitMode & BaseProps;
147
162
  /**
148
163
  * Action button.
149
164
  *
@@ -164,4 +179,4 @@ export type ButtonProps = BaseButtonProps & PriorityMode & ContentMode & SubmitM
164
179
  * <Button href="/docs" target="_blank" label="Read the docs" />
165
180
  * ```
166
181
  */
167
- export declare const Button: ({ priority, tone, underline, icon, iconTrailing, paddingless, label, size, loading, disabled, selected, round, href, type, form, copy, copyNode, copyLazy, onCopied, onCopyRequest, className, style, children, ...rest }: ButtonProps) => any;
182
+ export declare const Button: ({ priority, tone, underline, icon, iconTrailing, paddingless, label, size, loading, disabled, selected, round, href, type, form, copy, copyNode, copyUrl, copyWithUrl, onCopied, onCopyRequest, className, style, children, ...rest }: ButtonProps) => any;
@@ -18,7 +18,8 @@ const Button = ({
18
18
  form,
19
19
  copy,
20
20
  copyNode,
21
- copyLazy,
21
+ copyUrl,
22
+ copyWithUrl,
22
23
  onCopied,
23
24
  onCopyRequest,
24
25
  className,
@@ -26,7 +27,7 @@ const Button = ({
26
27
  children,
27
28
  ...rest
28
29
  }) => {
29
- const isCopy = copy != null || copyNode != null || copyLazy === true;
30
+ const isCopy = copy != null || copyNode != null || copyUrl === true;
30
31
  const toneAttr = tone || void 0;
31
32
  const computedStyle = roundStyle(round, "--button-round", toneStyle(toneAttr, "--button-tone-source", style));
32
33
  const leadingIcon = icon ?? (isCopy ? "copy" : void 0);
@@ -67,14 +68,15 @@ const Button = ({
67
68
  // Copy behavior — the element reads these and performs the write itself.
68
69
  copy: copy != null ? copy : void 0,
69
70
  "copy-node": copyNode === true ? "" : typeof copyNode === "string" ? copyNode : void 0,
70
- "copy-lazy": copyLazy ? "" : void 0,
71
+ "copy-url": copyUrl ? "" : void 0,
72
+ "copy-with-url": copyWithUrl ? "" : void 0,
71
73
  // Marks the control so `copy-node` serialization strips it from the copied
72
74
  // node (a copy button inside the copied region shouldn't paste itself).
73
75
  "data-copy-node-button": copyNode != null && copyNode !== false ? "" : void 0,
74
76
  oncopydone: onCopied ? (e) => onCopied(nativeStateChange(e).detail?.ok ?? false) : void 0,
75
- // The element hands `provide` on the request event; forward it so the
76
- // consumer supplies the lazily-computed content.
77
- oncopyrequest: onCopyRequest ? (e) => onCopyRequest(nativeStateChange(e).detail.provide) : void 0,
77
+ // Fired on pointerdown for a lazy copy — the consumer updates `copy` here
78
+ // (a state change) and the click copies the latest value. No payload crosses.
79
+ oncopyrequest: onCopyRequest ? () => onCopyRequest() : void 0,
78
80
  class: className,
79
81
  style: computedStyle
80
82
  };
@@ -7,15 +7,16 @@ export type ButtonCopyProps = ButtonProps & ({
7
7
  } | {
8
8
  copyNode: boolean | string;
9
9
  } | {
10
- copyLazy: true;
10
+ copyUrl: true;
11
11
  });
12
12
  /**
13
13
  * Copy button — a `Button` preset for copy-to-clipboard. Set `copy` for a literal
14
- * string, `copyNode` to copy a DOM region, or `copyLazy` + `onCopyRequest` to
15
- * compute the content on click. The `<a-button>` element performs the write and
16
- * reports the result; this wrapper flips the leading icon to a check (success) or
17
- * ✕ (failure) and retones to `success` / `critical` for ~2s — a JSX re-render, no
18
- * element state (see `useCopyFeedback`).
14
+ * string, `copyNode` to copy a DOM region, or `copyUrl` to copy the current page
15
+ * URL. For content computed on demand, keep `copy` reactive and refresh it in
16
+ * `onCopyRequest` (fired on pointerdown). The `<a-button>` element performs the
17
+ * write and reports the result; this wrapper flips the leading icon to a check
18
+ * (success) or ✕ (failure) and retones to `success` / `critical` for ~2s — a JSX
19
+ * re-render, no element state (see `useCopyFeedback`).
19
20
  *
20
21
  * With a `label` it's a labeled button; without one it's an icon-only copy button.
21
22
  * Everything else is a normal `Button` prop — `tone`, `priority`, `size`, `icon`
@@ -26,7 +27,12 @@ export type ButtonCopyProps = ButtonProps & ({
26
27
  * <ButtonCopy copy="npm i @antadesign/anta" label="Copy install" />
27
28
  * <ButtonCopy copy="https://anta.design" /> // icon-only
28
29
  * <ButtonCopy copyNode=".snippet" label="Copy block" priority="tertiary" />
29
- * <ButtonCopy copyLazy label="Copy report" onCopyRequest={(p) => p(buildReport())} />
30
+ * <ButtonCopy copyUrl label="Copy link" /> // copies location.href
31
+ * <ButtonCopy copy={code} copyWithUrl label="Copy snippet" /> // code + source URL
32
+ *
33
+ * // Lazy: compute on pointerdown, copied on the click that follows.
34
+ * const [report, setReport] = useState('')
35
+ * <ButtonCopy copy={report} onCopyRequest={() => setReport(buildReport())} label="Copy report" />
30
36
  * ```
31
37
  */
32
38
  export declare const ButtonCopy: ({ icon, tone, onCopied, ...rest }: ButtonCopyProps) => any;
@@ -86,7 +86,8 @@ export type MenuItemLinkMode = {
86
86
  indicator?: never;
87
87
  copy?: never;
88
88
  copyNode?: never;
89
- copyLazy?: never;
89
+ copyUrl?: never;
90
+ copyWithUrl?: never;
90
91
  onCopied?: never;
91
92
  onCopyRequest?: never;
92
93
  };
@@ -141,4 +142,4 @@ export type MenuItemProps = MenuItemCommonProps & (MenuItemLinkMode | (MenuItemA
141
142
  * </MenuItem>
142
143
  * ```
143
144
  */
144
- export declare const MenuItem: ({ icon, label, hint, kbd, iconTrailing, disabled, selected, selectionIndicator, indeterminate, indicator, tone, toneSelected, submenu, value, onSelect, href, target, rel, download, ping, copy, copyNode, copyLazy, onCopied, onCopyRequest, role: roleOverride, className, style, children, ...rest }: MenuItemProps) => any;
145
+ export declare const MenuItem: ({ icon, label, hint, kbd, iconTrailing, disabled, selected, selectionIndicator, indeterminate, indicator, tone, toneSelected, submenu, value, onSelect, href, target, rel, download, ping, copy, copyNode, copyUrl, copyWithUrl, onCopied, onCopyRequest, role: roleOverride, className, style, children, ...rest }: MenuItemProps) => any;
@@ -23,7 +23,8 @@ const MenuItem = ({
23
23
  ping,
24
24
  copy,
25
25
  copyNode,
26
- copyLazy,
26
+ copyUrl,
27
+ copyWithUrl,
27
28
  onCopied,
28
29
  onCopyRequest,
29
30
  role: roleOverride,
@@ -32,7 +33,7 @@ const MenuItem = ({
32
33
  children,
33
34
  ...rest
34
35
  }) => {
35
- const isCopy = copy != null || copyNode != null || copyLazy === true;
36
+ const isCopy = copy != null || copyNode != null || copyUrl === true;
36
37
  const leadingIcon = icon ?? (isCopy ? "copy" : void 0);
37
38
  const checkable = selectionIndicator === "checkbox" || selectionIndicator === "radio" || selectionIndicator === "check";
38
39
  const role = selectionIndicator === "checkbox" ? "menuitemcheckbox" : selectionIndicator === "radio" || selectionIndicator === "check" ? "menuitemradio" : "menuitem";
@@ -85,10 +86,11 @@ const MenuItem = ({
85
86
  onmenuselect: onSelect ? (e) => onSelect(e, { value, label }) : void 0,
86
87
  copy: copy != null ? copy : void 0,
87
88
  "copy-node": copyNode === true ? "" : typeof copyNode === "string" ? copyNode : void 0,
88
- "copy-lazy": copyLazy ? "" : void 0,
89
+ "copy-url": copyUrl ? "" : void 0,
90
+ "copy-with-url": copyWithUrl ? "" : void 0,
89
91
  "data-copy-node-button": copyNode != null && copyNode !== false ? "" : void 0,
90
92
  oncopydone: onCopied ? (e) => onCopied(nativeStateChange(e).detail?.ok ?? false) : void 0,
91
- oncopyrequest: onCopyRequest ? (e) => onCopyRequest(nativeStateChange(e).detail.provide) : void 0,
93
+ oncopyrequest: onCopyRequest ? () => onCopyRequest() : void 0,
92
94
  class: className,
93
95
  ...rest,
94
96
  children: [
@@ -8,12 +8,13 @@ export type MenuItemCopyProps = MenuItemCommonProps & MenuItemActionMode & CopyM
8
8
  } | {
9
9
  copyNode: boolean | string;
10
10
  } | {
11
- copyLazy: true;
11
+ copyUrl: true;
12
12
  });
13
13
  /**
14
14
  * Copying menu item — a `MenuItem` preset for copy-to-clipboard rows inside a
15
15
  * `Menu`. Set `copy` for a literal string, `copyNode` to copy a DOM region, or
16
- * `copyLazy` + `onCopyRequest` to compute the content on select. The
16
+ * `copyUrl` to copy the current page URL. For content computed on demand, keep
17
+ * `copy` reactive and refresh it in `onCopyRequest` (fired on pointerdown). The
17
18
  * `<a-menu-item>` element performs the write when the row is chosen; this wrapper
18
19
  * flips the leading icon to a check / ✕ and retones for ~2s (see `useCopyFeedback`).
19
20
  *
@@ -1,5 +1,6 @@
1
1
  import type { BaseProps } from '../general_types';
2
2
  import type { IconShape } from '../elements/a-icon.shapes';
3
+ import { type MenuProps } from './Menu';
3
4
  /** One option in a `<Select>`. Pass a bare string as shorthand for
4
5
  * `{ value: s, label: s }`. Carries an index signature so you can attach
5
6
  * arbitrary fields (a `ranAt` date, a `status`, …) and read them back in
@@ -131,6 +132,13 @@ export interface SelectCommonProps extends Omit<BaseProps, 'children'> {
131
132
  * logical pick (both rows toggle together; the trigger resolves to the last). Dev
132
133
  * builds `console.warn` on a duplicate. */
133
134
  options: SelectItem[];
135
+ /** Preferred placement of the options menu relative to the trigger. The menu
136
+ * auto-flips vertically and clamps horizontally when needed.
137
+ * @defaultValue bottom-start */
138
+ placement?: MenuProps['placement'];
139
+ /** Gap in pixels between the trigger and the options menu.
140
+ * @defaultValue 4 */
141
+ offset?: number;
134
142
  /** The per-row mark for **single**-select: `'none'` (a tint-only highlight),
135
143
  * `'check'` (a trailing checkmark on the selected row, keeping the tint — the
136
144
  * canonical Select look), or `'radio'` (a leading radio on every row).
@@ -37,6 +37,8 @@ function optionsWithSelection(options, values) {
37
37
  const Select = (props) => {
38
38
  const {
39
39
  options,
40
+ placement,
41
+ offset,
40
42
  selection,
41
43
  indicator,
42
44
  value,
@@ -274,6 +276,8 @@ const Select = (props) => {
274
276
  /* @__PURE__ */ jsxs(
275
277
  Menu,
276
278
  {
279
+ placement,
280
+ offset,
277
281
  onStateChange: (_e, { next }) => {
278
282
  setOpen(next);
279
283
  if (!next) {
@@ -1,6 +1,7 @@
1
1
  import type { BaseProps } from '../general_types';
2
2
  import type { IconShape } from '../elements/a-icon.shapes';
3
3
  import type { SelectItem, SelectOption } from './Select';
4
+ import { type MenuProps } from './Menu';
4
5
  interface FacetBase {
5
6
  /** The facet's namespace — the key its value is stored under in the value
6
7
  * record, and what `onValueChange`'s `attrs.facet` reports. Unique across
@@ -108,6 +109,13 @@ export interface SelectFacetedTriggerState {
108
109
  export interface SelectFacetedProps extends Omit<BaseProps, 'children'> {
109
110
  /** The facets (dimensions) to filter across, each with its own editor kind. */
110
111
  facets: SelectFacet[];
112
+ /** Preferred placement of the root filter menu relative to its trigger. The
113
+ * menu auto-flips vertically and clamps horizontally when needed.
114
+ * @defaultValue bottom-start */
115
+ placement?: MenuProps['placement'];
116
+ /** Gap in pixels between the trigger and the filter menu.
117
+ * @defaultValue 4 */
118
+ offset?: number;
111
119
  /** Controlled value — the facet-keyed record. When provided, the consumer
112
120
  * owns state: a pick only *requests* a change via `onValueChange` (reject by
113
121
  * not updating). Leave undefined for uncontrolled. */
@@ -27,6 +27,8 @@ const defaultMatch = (o, query) => matchesQuery(o, matchQueryRegex(query));
27
27
  const SelectFaceted = (props) => {
28
28
  const {
29
29
  facets,
30
+ placement,
31
+ offset,
30
32
  value,
31
33
  defaultValue,
32
34
  onValueChange,
@@ -284,6 +286,8 @@ const SelectFaceted = (props) => {
284
286
  /* @__PURE__ */ jsxs(
285
287
  Menu,
286
288
  {
289
+ placement,
290
+ offset,
287
291
  open,
288
292
  onStateChange: (_e, { next }) => {
289
293
  setOpen(next);
@@ -64,8 +64,11 @@ declare global {
64
64
  export declare class AButtonElement extends HTMLElementBase {
65
65
  connectedCallback(): void;
66
66
  /** Perform a copy if this button is a copy control (`copy` / `copy-node` /
67
- * `copy-lazy`). Called from the delegated click handler on activation. The
67
+ * `copy-url`). Called from the delegated click handler on activation. The
68
68
  * outcome is announced via a `copydone` event the wrapper reflects. */
69
69
  performCopy(): boolean;
70
+ /** Ask a lazy consumer to refresh the `copy` value on pointerdown (fires a
71
+ * `copyrequest` event); the click then copies whatever `copy` holds. */
72
+ requestCopy(): void;
70
73
  }
71
74
  export declare function register_a_button(): void;
@@ -1,9 +1,17 @@
1
1
  import { HTMLElementBase } from "../anta_helpers";
2
- import { runCopy } from "./copy-behavior";
2
+ import { emitCopyRequest, runCopy } from "./copy-behavior";
3
+ import { installKeyActivation } from "./key-activation";
3
4
  import "./a-button.css";
4
5
  function installDocumentHandlers(doc) {
5
6
  if (!doc || doc.hasKeyListenerForAButton) return;
6
- doc.addEventListener("keydown", handleKeyDown, true);
7
+ installKeyActivation(doc, {
8
+ keys: ["Enter", " "],
9
+ resolve: (t) => t?.closest?.("a-button"),
10
+ blocked: (el) => el.hasAttribute("disabled") || el.hasAttribute("loading"),
11
+ preflight: (el) => el.requestCopy(),
12
+ activate: (el) => el.click()
13
+ });
14
+ doc.addEventListener("pointerdown", handlePointerDown, true);
7
15
  doc.addEventListener("click", handleClick, true);
8
16
  doc.hasKeyListenerForAButton = true;
9
17
  }
@@ -12,11 +20,16 @@ class AButtonElement extends HTMLElementBase {
12
20
  installDocumentHandlers(this.doc);
13
21
  }
14
22
  /** Perform a copy if this button is a copy control (`copy` / `copy-node` /
15
- * `copy-lazy`). Called from the delegated click handler on activation. The
23
+ * `copy-url`). Called from the delegated click handler on activation. The
16
24
  * outcome is announced via a `copydone` event the wrapper reflects. */
17
25
  performCopy() {
18
26
  return runCopy(this);
19
27
  }
28
+ /** Ask a lazy consumer to refresh the `copy` value on pointerdown (fires a
29
+ * `copyrequest` event); the click then copies whatever `copy` holds. */
30
+ requestCopy() {
31
+ emitCopyRequest(this);
32
+ }
20
33
  }
21
34
  function findForm(el) {
22
35
  const formId = el.getAttribute("form");
@@ -25,16 +38,13 @@ function findForm(el) {
25
38
  }
26
39
  return el.closest("form");
27
40
  }
28
- function handleKeyDown(e) {
29
- if (e.key !== "Enter" && e.key !== " ") return;
30
- if (e.repeat) return;
41
+ function handlePointerDown(e) {
31
42
  const el = e.target.closest(
32
43
  "a-button"
33
44
  );
34
45
  if (!el) return;
35
46
  if (el.hasAttribute("disabled") || el.hasAttribute("loading")) return;
36
- e.preventDefault();
37
- el.click();
47
+ el.requestCopy();
38
48
  }
39
49
  function handleClick(e) {
40
50
  const el = e.target.closest(
@@ -1,6 +1,12 @@
1
1
  import { HTMLElementBase, parseOpenState } from "../anta_helpers";
2
2
  import "./a-expander.css";
3
3
  const ANIM_MS = 200;
4
+ const INTERACTIVE_SELECTOR = 'a-button,a-checkbox,a-radio,a-radio-group,a-input,a-input-time,a-calendar,a-menu-item,a-tab,[data-anta][role="button"],button,a[href],input,select,textarea,label,[contenteditable=""],[contenteditable="true"]';
5
+ const DISABLED_SELECTOR = ':disabled,[disabled],[aria-disabled="true"]';
6
+ function ignoresToggle(el) {
7
+ if (el.matches("[data-expander-ignore]")) return true;
8
+ return el.matches(INTERACTIVE_SELECTOR) && !el.matches(DISABLED_SELECTOR);
9
+ }
4
10
  const SUMMARY_TYPE_SCALE = {
5
11
  "1": [28, 32],
6
12
  "2": [24, 28],
@@ -266,11 +272,24 @@ class AExpanderElement extends HTMLElementBase {
266
272
  } catch {
267
273
  }
268
274
  }
275
+ /** Refuse to toggle when the click landed on a control inside the projected
276
+ * title — walk the composed path up to (not including) the summary button and
277
+ * test each element with `ignoresToggle` (enabled interactive control or the
278
+ * opt-out marker). Synchronous and read here in the element, so it stands in
279
+ * off-UI-thread runtimes. Suppressing before the dispatch also keeps a
280
+ * *controlled* consumer from toggling (no `statechange` fires). */
281
+ #clickIgnoresToggle(e) {
282
+ const path = e.composedPath();
283
+ const end = path.indexOf(this.summary);
284
+ const scope = end === -1 ? path : path.slice(0, end);
285
+ return scope.some((n) => n instanceof Element && ignoresToggle(n));
286
+ }
269
287
  /** Compute the requested next state, announce it (cancelable, *before*
270
288
  * applying), then — uncontrolled only, and only if not vetoed — apply it.
271
289
  * Controlled: never self-apply; the consumer answers via the `state`
272
290
  * attribute. See STATEFUL-COMPONENTS.md. */
273
- onSummaryClick = () => {
291
+ onSummaryClick = (e) => {
292
+ if (this.#clickIgnoresToggle(e)) return;
274
293
  const prev = this.#current;
275
294
  const next = prev === "open" ? "closed" : "open";
276
295
  const ok = this.dispatchEvent(
@@ -67,6 +67,13 @@ export declare function isMenuItemEl(el: EventTarget | null | undefined): el is
67
67
  /** Install the one-per-document Enter/Space activation listener. Called from
68
68
  * both `AMenuItemElement` and `AMenuElement` on connect, so a menu built only
69
69
  * from link items (no `<a-menu-item>` ever upgrades) still gets keyboard
70
- * activation. Idempotent per document. */
70
+ * activation. Idempotent per document.
71
+ *
72
+ * Shares `installKeyActivation` with `<a-button>`: activation is on keyup, with
73
+ * a keydown pre-request (`emitCopyRequest`) so a lazy copy row can refresh its
74
+ * `copy` during the hold. `.click()` on a link item navigates natively (honoring
75
+ * download / target); on the custom element it routes through the menu's click
76
+ * delegation — one path for both. (The combobox-filter Enter is a separate path
77
+ * handled by `a-menu`, which uses the same keyup pre-request.) */
71
78
  export declare function ensureMenuItemKeyListener(doc: Document): void;
72
79
  export declare function register_a_menu_item(): void;
@@ -1,5 +1,6 @@
1
1
  import { HTMLElementBase } from "../anta_helpers";
2
- import { runCopy } from "./copy-behavior";
2
+ import { emitCopyRequest, runCopy } from "./copy-behavior";
3
+ import { installKeyActivation } from "./key-activation";
3
4
  import "./a-menu-item.css";
4
5
  class AMenuItemElement extends HTMLElementBase {
5
6
  internals;
@@ -7,6 +8,7 @@ class AMenuItemElement extends HTMLElementBase {
7
8
  super();
8
9
  this.internals = this.attachInternals?.();
9
10
  this.addEventListener("menuselect", () => runCopy(this));
11
+ this.addEventListener("pointerdown", () => emitCopyRequest(this));
10
12
  }
11
13
  /** The active (combobox) cursor. `a-menu` sets this **property** (never an
12
14
  * attribute — no DOM mutation, same rule as `a-radio.selected`) as ArrowUp/Down
@@ -30,17 +32,15 @@ function isMenuItemEl(el) {
30
32
  }
31
33
  function ensureMenuItemKeyListener(doc) {
32
34
  if (doc.hasKeyListenerForAMenuItem) return;
33
- doc.addEventListener("keydown", handleKeyDown, true);
35
+ installKeyActivation(doc, {
36
+ keys: ["Enter", " "],
37
+ resolve: (t) => t?.closest?.(MENU_ITEM_SELECTOR),
38
+ blocked: (el) => el.hasAttribute("disabled"),
39
+ preflight: (el) => emitCopyRequest(el),
40
+ activate: (el) => el.click()
41
+ });
34
42
  doc.hasKeyListenerForAMenuItem = true;
35
43
  }
36
- function handleKeyDown(e) {
37
- if (e.key !== "Enter" && e.key !== " ") return;
38
- const el = e.target?.closest?.(MENU_ITEM_SELECTOR);
39
- if (!el) return;
40
- e.preventDefault();
41
- if (el.hasAttribute("disabled")) return;
42
- el.click();
43
- }
44
44
  function register_a_menu_item() {
45
45
  if (typeof customElements === "undefined") return;
46
46
  if (!customElements.get("a-menu-item")) {
@@ -122,6 +122,9 @@ export declare class AMenuElement extends HTMLElementBase {
122
122
  * (line start/end); PageUp / PageDown jump the option list to first / last
123
123
  * instead. Any other key falls through to the input (typing). */
124
124
  private handleComboKey;
125
+ /** Combobox keyup: activate the item a filter-field Enter armed. Paired with the
126
+ * keydown pre-request in `handleComboKey` so lazy content resolves in the gap. */
127
+ handleKeyUp(e: KeyboardEvent): void;
125
128
  /** Public imperative API. Routes through the same intent path as the
126
129
  * triggers, so it emits `statechange` and respects controlled mode. */
127
130
  open(opts?: {
@@ -212,8 +215,9 @@ export declare class AMenuElement extends HTMLElementBase {
212
215
  /** Close the whole open menu system from the root down. */
213
216
  private closeSystem;
214
217
  /** Called by the coordinator on the topmost open menu. Handles navigation;
215
- * Enter / Space activation is handled by a-menu-item's own global keydown
216
- * (which synthesizes a click → routed through onSurfaceClick). */
218
+ * roving Enter / Space activation is `<a-menu-item>`'s own delegated keyup
219
+ * (synthesizes a click → routed through onSurfaceClick). Combobox Enter (focus
220
+ * in the filter field) is handled here via handleComboKey / handleKeyUp. */
217
221
  handleKey(e: KeyboardEvent): void;
218
222
  private submenuOf;
219
223
  private typeahead;
@@ -1,5 +1,6 @@
1
1
  import { HTMLElementBase, anchorRect, setMenuPresence } from "../anta_helpers";
2
2
  import { AMenuItemElement, isMenuItemEl, ensureMenuItemKeyListener } from "./a-menu-item";
3
+ import { emitCopyRequest } from "./copy-behavior";
3
4
  import "./a-menu.css";
4
5
  const MARGIN = 4;
5
6
  const MIN_HEIGHT = 96;
@@ -18,6 +19,7 @@ function bindDocListeners(doc, view) {
18
19
  if (docBound) return;
19
20
  doc.addEventListener("pointerdown", onDocPointerDown, true);
20
21
  doc.addEventListener("keydown", onDocKeyDown, true);
22
+ doc.addEventListener("keyup", onDocKeyUp, true);
21
23
  doc.addEventListener("contextmenu", onDocContextMenu, true);
22
24
  view.addEventListener("resize", onResize);
23
25
  boundDoc = doc;
@@ -28,6 +30,7 @@ function unbindDocListeners() {
28
30
  if (!docBound) return;
29
31
  boundDoc?.removeEventListener("pointerdown", onDocPointerDown, true);
30
32
  boundDoc?.removeEventListener("keydown", onDocKeyDown, true);
33
+ boundDoc?.removeEventListener("keyup", onDocKeyUp, true);
31
34
  boundDoc?.removeEventListener("contextmenu", onDocContextMenu, true);
32
35
  boundView?.removeEventListener("resize", onResize);
33
36
  removePosTracker?.();
@@ -66,6 +69,14 @@ function pathHitsMenus(e, primaryClick = false) {
66
69
  }
67
70
  return false;
68
71
  }
72
+ function pathCrossesTopLayerBeforeAnchor(e, anchor) {
73
+ for (const node of e.composedPath()) {
74
+ if (node === anchor) return false;
75
+ if (node instanceof HTMLDialogElement && node.matches(":modal") || node instanceof HTMLElement && node.matches(":popover-open"))
76
+ return true;
77
+ }
78
+ return false;
79
+ }
69
80
  function nodeHitsMenus(node) {
70
81
  for (const m of openStack) {
71
82
  if (m.contains(node) || m.surface.contains(node)) return true;
@@ -95,6 +106,11 @@ function onDocKeyDown(e) {
95
106
  if (!menu) return;
96
107
  menu.handleKey(e);
97
108
  }
109
+ function onDocKeyUp(e) {
110
+ const menu = openStack[openStack.length - 1];
111
+ if (!menu) return;
112
+ menu.handleKeyUp(e);
113
+ }
98
114
  function dismiss(originEvent) {
99
115
  openStack[0]?.requestClose(originEvent);
100
116
  }
@@ -535,13 +551,26 @@ class AMenuElement extends HTMLElementBase {
535
551
  case "Enter":
536
552
  if (this.activeItem) {
537
553
  e.preventDefault();
538
- this.activeItem.click();
554
+ emitCopyRequest(this.activeItem);
555
+ this.#pendingComboActivate = this.activeItem;
539
556
  }
540
557
  return true;
541
558
  default:
542
559
  return false;
543
560
  }
544
561
  }
562
+ /** The combobox cursor armed by an Enter keydown in the filter field, consumed
563
+ * on the matching keyup. Only the combobox path arms this — the roving Enter/
564
+ * Space activation is `<a-menu-item>`'s (also keyup, via key-activation). */
565
+ #pendingComboActivate = null;
566
+ /** Combobox keyup: activate the item a filter-field Enter armed. Paired with the
567
+ * keydown pre-request in `handleComboKey` so lazy content resolves in the gap. */
568
+ handleKeyUp(e) {
569
+ if (e.key !== "Enter") return;
570
+ const el = this.#pendingComboActivate;
571
+ this.#pendingComboActivate = null;
572
+ el?.click();
573
+ }
545
574
  /* ============================ open / close ============================ */
546
575
  /** Public imperative API. Routes through the same intent path as the
547
576
  * triggers, so it emits `statechange` and respects controlled mode. */
@@ -863,8 +892,9 @@ class AMenuElement extends HTMLElementBase {
863
892
  }
864
893
  /* ============================ keyboard ============================ */
865
894
  /** Called by the coordinator on the topmost open menu. Handles navigation;
866
- * Enter / Space activation is handled by a-menu-item's own global keydown
867
- * (which synthesizes a click → routed through onSurfaceClick). */
895
+ * roving Enter / Space activation is `<a-menu-item>`'s own delegated keyup
896
+ * (synthesizes a click → routed through onSurfaceClick). Combobox Enter (focus
897
+ * in the filter field) is handled here via handleComboKey / handleKeyUp. */
868
898
  handleKey(e) {
869
899
  const active = this.doc.activeElement;
870
900
  if (e.key === "Escape") {
@@ -998,6 +1028,7 @@ class AMenuElement extends HTMLElementBase {
998
1028
  };
999
1029
  } else if (this.#isContext) {
1000
1030
  const onContext = (e) => {
1031
+ if (pathCrossesTopLayerBeforeAnchor(e, anchor)) return;
1001
1032
  e.preventDefault();
1002
1033
  this.requestOpen({ coord: [e.clientX, e.clientY], originEvent: e });
1003
1034
  };
@@ -11,23 +11,40 @@
11
11
  * nested copy row's `copydone` from flipping an ancestor copy row's feedback
12
12
  * (the same point-to-point rule Anta's `statechange` follows).
13
13
  *
14
- * Three modes, chosen by attribute:
14
+ * Modes, chosen by attribute:
15
15
  * - `copy="<text>"` — copy the literal string on activation.
16
+ * - `copy-url` — copy the current page URL (`location.href`); no `copy` needed.
17
+ * - `copy-with-url` — prefix the copied `copy` string with `// URL: <href>`.
16
18
  * - `copy-node` / `copy-node="<selector>"` — copy a DOM node as rich text
17
19
  * (`text/html`) + plain text. Bare `copy-node` copies the nearest ancestor
18
20
  * marked `[data-copy-source]`; a value is a selector resolved with `closest()`
19
21
  * (an ancestor region). The copy control is stripped from the serialized
20
22
  * output (`[data-copy-node-button]`).
21
- * - `copy-lazy` — the content isn't known until the click. Activation dispatches
22
- * `copyrequest` whose `detail.provide(text)` the consumer calls with the value
23
- * (synchronously, or after an `await` — the browser's transient-activation
24
- * window still covers the write). Nothing lands in the DOM, and there's no
25
- * attribute round-trip to reset. Takes precedence over any `copy` value.
23
+ *
24
+ * ## Lazy content (off-UI-thread safe)
25
+ *
26
+ * When the content isn't known ahead of time, the element emits a `copyrequest`
27
+ * event on **pointerdown** (`emitCopyRequest`, fired whenever a `copy` attribute
28
+ * is present). The consumer answers by setting the `copy` attribute to the
29
+ * freshly-computed value; the **click** then reads that value and writes it. The
30
+ * pointerdown→click gap absorbs the round-trip, so this works even when the
31
+ * consumer's handler runs off the UI thread (e.g. a worker-rendered app): only a
32
+ * serializable string crosses the boundary via the normal reconcile — no
33
+ * callback, and the write stays inside the click's transient activation. This is
34
+ * why there is no `provide`-function handoff and no `copy-lazy` marker.
26
35
  *
27
36
  * Precedence when several are set (the discriminated-union prop types make this
28
- * unreachable from the wrappers, but hand-authored markup can): node → lazy →
29
- * literal `copy`.
37
+ * unreachable from the wrappers, but hand-authored markup can): node → url →
38
+ * literal `copy` (with the optional URL prefix).
39
+ */
40
+ /**
41
+ * Ask a lazy consumer to refresh the copy content, on pointerdown — dispatch a
42
+ * non-bubbling, payload-free `copyrequest`. The consumer answers by setting the
43
+ * `copy` attribute to the freshly-computed value before the click reads it (see
44
+ * the "Lazy content" note above). Fires only for string-copy controls; node/url
45
+ * modes resolve themselves at click time and need no request. No-op otherwise.
30
46
  */
47
+ export declare function emitCopyRequest(el: HTMLElement): void;
31
48
  /**
32
49
  * Perform a copy on activation (click / Enter / Space / menu select). Returns
33
50
  * `true` if the element is a copy control (so the caller knows the copy path
@@ -37,32 +37,34 @@ function run(el, work) {
37
37
  el.dispatchEvent(new CustomEvent("copydone", { bubbles: false, detail: { ok } }));
38
38
  });
39
39
  }
40
+ function currentUrl() {
41
+ return typeof location !== "undefined" ? location.href : "";
42
+ }
43
+ function resolveText(el) {
44
+ if (el.hasAttribute("copy-url")) return currentUrl();
45
+ const base = el.getAttribute("copy") ?? "";
46
+ if (el.hasAttribute("copy-with-url")) return `// URL: ${currentUrl()}
47
+ ${base}`;
48
+ return base;
49
+ }
50
+ function isTextCopy(el) {
51
+ return el.hasAttribute("copy") || el.hasAttribute("copy-url");
52
+ }
53
+ function emitCopyRequest(el) {
54
+ if (!el.hasAttribute("copy")) return;
55
+ el.dispatchEvent(new CustomEvent("copyrequest", { bubbles: false }));
56
+ }
40
57
  function runCopy(el) {
41
58
  if (el.hasAttribute("copy-node")) {
42
59
  const target = resolveNodeTarget(el);
43
60
  run(el, target ? writeNode(target) : Promise.resolve(false));
44
61
  return true;
45
62
  }
46
- if (el.hasAttribute("copy-lazy")) {
47
- let done = false;
48
- el.dispatchEvent(
49
- new CustomEvent("copyrequest", {
50
- bubbles: false,
51
- detail: {
52
- provide(text) {
53
- if (done) return;
54
- done = true;
55
- run(el, writeText(String(text ?? "")));
56
- }
57
- }
58
- })
59
- );
60
- return true;
61
- }
62
- if (!el.hasAttribute("copy")) return false;
63
- run(el, writeText(el.getAttribute("copy") ?? ""));
63
+ if (!isTextCopy(el)) return false;
64
+ run(el, writeText(resolveText(el)));
64
65
  return true;
65
66
  }
66
67
  export {
68
+ emitCopyRequest,
67
69
  runCopy
68
70
  };
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Shared "activate on key release" for Enter / Space, used by `<a-button>` and
3
+ * `<a-menu-item>` (via one delegated keydown/keyup pair per document).
4
+ *
5
+ * Activation fires on **keyup**, not keydown. This matches the native `<button>`
6
+ * (Space already activates on release), makes activation **cancelable** (move
7
+ * focus between press and release → no fire), and — the reason it's uniform
8
+ * across Enter and Space here — opens a **keydown→keyup gap** so a lazy copy
9
+ * control can refresh its `copy` value between press and release before the write
10
+ * (the keyboard analog of the pointerdown→click gap; see copy-behavior's "Lazy
11
+ * content" note).
12
+ *
13
+ * keydown: `preventDefault()` (stop Space scrolling the page and a link's own
14
+ * native Enter→click), run `preflight(el)` — the copy pre-request — and arm the
15
+ * release. keyup: `activate(el)` only if the same element is still armed.
16
+ *
17
+ * Idempotency is the caller's job (each guards with its own per-document flag);
18
+ * this just wires one keydown/keyup pair.
19
+ */
20
+ export interface KeyActivation {
21
+ /** Keys that activate — `['Enter', ' ']` for buttons/menu items. */
22
+ keys: readonly string[];
23
+ /** Resolve the activatable element from the event target (e.g. `closest`). */
24
+ resolve(target: EventTarget | null): HTMLElement | null;
25
+ /** Blocked (disabled / loading) — swallow the key without activating. */
26
+ blocked?(el: HTMLElement): boolean;
27
+ /** Runs on keydown, before the release: the lazy copy pre-request. */
28
+ preflight?(el: HTMLElement): void;
29
+ /** Runs on keyup: the actual activation (a synthesized `click()`). */
30
+ activate(el: HTMLElement): void;
31
+ }
32
+ export declare function installKeyActivation(doc: Document, config: KeyActivation): void;
@@ -0,0 +1,33 @@
1
+ function installKeyActivation(doc, config) {
2
+ let armed = null;
3
+ doc.addEventListener(
4
+ "keydown",
5
+ (e) => {
6
+ if (e.repeat || !config.keys.includes(e.key)) return;
7
+ const el = config.resolve(e.target);
8
+ if (!el) return;
9
+ e.preventDefault();
10
+ armed = config.blocked?.(el) ? null : el;
11
+ if (armed) config.preflight?.(armed);
12
+ },
13
+ true
14
+ );
15
+ doc.addEventListener(
16
+ "keyup",
17
+ (e) => {
18
+ if (!config.keys.includes(e.key)) return;
19
+ const el = config.resolve(e.target);
20
+ if (!el || el !== armed) {
21
+ armed = null;
22
+ return;
23
+ }
24
+ armed = null;
25
+ if (config.blocked?.(el)) return;
26
+ config.activate(el);
27
+ },
28
+ true
29
+ );
30
+ }
31
+ export {
32
+ installKeyActivation
33
+ };
@@ -735,17 +735,19 @@ export interface AMenuItemAttributes extends BaseAttributes {
735
735
  * is a CSS selector resolved with `closest()`. Presence-based for the boolean
736
736
  * form. */
737
737
  'copy-node'?: boolean | string;
738
- /** Lazy copy: with an empty `copy`, activation dispatches `copyrequest` and the
739
- * write completes once `copy` is set back (within the activation window).
738
+ /** Copy the current page URL (`location.href`) instead of a `copy` string.
740
739
  * Presence-based. */
741
- 'copy-lazy'?: boolean | '';
740
+ 'copy-url'?: boolean | '';
741
+ /** Prefix the copied `copy` string with `// URL: <href>`. Presence-based. */
742
+ 'copy-with-url'?: boolean | '';
742
743
  /** Fired after a copy attempt (bubbling `copydone`, `detail: { ok }`).
743
744
  * All-lowercase so it binds in React *and* Preact. */
744
745
  oncopydone?: (e: CustomEvent<{
745
746
  ok: boolean;
746
747
  }>) => void;
747
- /** Fired on a lazy copy activation to request the content (bubbling
748
- * `copyrequest`). Answer by setting the `copy` attribute. All-lowercase. */
748
+ /** Fired on **pointerdown** for a string-copy row (`copyrequest`, non-bubbling).
749
+ * Answer by setting the `copy` attribute to the freshly-computed value; the
750
+ * chosen row copies it. All-lowercase. */
749
751
  oncopyrequest?: (e: CustomEvent) => void;
750
752
  }
751
753
  /**
@@ -798,22 +800,22 @@ export interface AButtonAttributes extends BaseAttributes {
798
800
  * is a CSS selector resolved with `closest()` (an ancestor region). Presence-
799
801
  * based for the boolean form. */
800
802
  'copy-node'?: boolean | string;
801
- /** Lazy copy: activation dispatches `copyrequest`; the consumer calls
802
- * `detail.provide(text)` with the content, which stays out of the DOM until
803
- * then. Takes precedence over `copy`. Presence-based. */
804
- 'copy-lazy'?: boolean | '';
803
+ /** Copy the current page URL (`location.href`) instead of a `copy` string; no
804
+ * `copy` needed. Presence-based. */
805
+ 'copy-url'?: boolean | '';
806
+ /** Prefix the copied `copy` string with `// URL: <href>`. Presence-based. */
807
+ 'copy-with-url'?: boolean | '';
805
808
  /** Fired after a copy attempt (`copydone`, `detail: { ok }`, non-bubbling so a
806
809
  * nested copy row can't flip an ancestor's feedback). All-lowercase so it
807
810
  * binds in React *and* Preact. */
808
811
  oncopydone?: (e: CustomEvent<{
809
812
  ok: boolean;
810
813
  }>) => void;
811
- /** Fired on a lazy copy activation (`copyrequest`, non-bubbling). Supply the
812
- * content by calling `detail.provide(text)` — synchronously or within the
813
- * click's activation window. All-lowercase. */
814
- oncopyrequest?: (e: CustomEvent<{
815
- provide: (text: string) => void;
816
- }>) => void;
814
+ /** Fired on **pointerdown** for a string-copy button (`copyrequest`,
815
+ * non-bubbling). Answer by setting the `copy` attribute to the freshly-computed
816
+ * value; the click then copies it. The pointerdown→click gap lets an
817
+ * off-UI-thread handler set `copy` in time. All-lowercase. */
818
+ oncopyrequest?: (e: CustomEvent) => void;
817
819
  'aria-disabled'?: 'true' | 'false' | boolean;
818
820
  'aria-busy'?: 'true' | 'false' | boolean;
819
821
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@antadesign/anta",
3
- "version": "0.3.10",
3
+ "version": "0.3.11",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",