@antadesign/anta 0.3.13 → 0.3.14

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.
@@ -29,9 +29,17 @@ export declare function wrapLabel(kids: React.ReactNode, tag: string): React.Rea
29
29
  * `currentTarget` is still live. (Read as `true` for the falsy-event guard so a
30
30
  * missing event never spuriously reads as bubbled.)
31
31
  */
32
- export declare function nativeStateChange<D>(e: CustomEvent<D> | {
32
+ /**
33
+ * The event a cross-renderer `statechange` handler receives: React wraps the native
34
+ * `CustomEvent` in a synthetic event (`.nativeEvent`); Preact passes it directly. `D`
35
+ * is the per-component `detail` vocabulary. Pair with `nativeStateChange` to unwrap.
36
+ * Exported so the stateful wrappers (`Banner`, and future ones) don't each re-declare
37
+ * this union — the CLAUDE.md "reuse shared wrapper helpers" rule.
38
+ */
39
+ export type StateChangeEvent<D> = CustomEvent<D> | {
33
40
  nativeEvent: CustomEvent<D>;
34
- }): {
41
+ };
42
+ export declare function nativeStateChange<D>(e: StateChangeEvent<D>): {
35
43
  event: CustomEvent<D>;
36
44
  detail?: D;
37
45
  isOwn: boolean;
@@ -0,0 +1,79 @@
1
+ import type { BaseProps } from "../general_types";
2
+ /** Public props for the `<Banner>` message strip. `message` is the leading
3
+ * content, `children` sit between it and `actions`, all centered in one row. */
4
+ export interface BannerProps extends BaseProps {
5
+ /** Leading message — a string (rendered at the banner type scale) or any node.
6
+ * Centered in the bar, followed by `children` and then `actions`. */
7
+ message?: React.ReactNode;
8
+ /** Trailing controls (buttons, links) rendered as a compact row after the
9
+ * message and `children`. */
10
+ actions?: React.ReactNode;
11
+ /** Free content placed BETWEEN the message and the actions. */
12
+ children?: React.ReactNode;
13
+ /** Semantic tone, or any literal CSS color (`'#ff1493'`, `'rebeccapurple'`) for
14
+ * a one-off custom tone. Named tones re-point the surface, text, and border
15
+ * colour (used if you opt into a border); a custom color keeps its hue while
16
+ * lightness/chroma are pinned. `'neutral'` (the default) is the same as omitting it.
17
+ * @defaultValue 'neutral' */
18
+ tone?: "neutral" | "brand" | "info" | "success" | "warning" | "critical" | (string & {});
19
+ /** Rounded corners for a standalone, floating banner (`border-radius: 999px`,
20
+ * which clamps to a stadium) instead of the edge-to-edge strip. Still borderless
21
+ * by default. Pass a `number` (px) or a CSS length string for a custom radius. */
22
+ round?: boolean | number | string;
23
+ /** Whether the trailing ✕ dismiss button is present. `false` removes it — drive
24
+ * dismissal yourself (a controlled `dismissed`, or your own control) instead.
25
+ * It doesn't make the banner un-dismissible, it just drops the built-in ✕.
26
+ * @defaultValue true */
27
+ closable?: boolean;
28
+ /** ARIA role for the strip host. `'status'` (a polite live region, so the notice
29
+ * reaches assistive tech) by default; pass `'alert'` for an urgent notice that
30
+ * should interrupt, or a landmark role.
31
+ * @defaultValue 'status' */
32
+ role?: string;
33
+ /** Controlled dismissed state. When provided, the consumer owns visibility: the
34
+ * banner follows this prop only, and clicking ✕ *requests* dismissal (reject by
35
+ * not updating). **Requires `onDismiss`** — controlled mode never self-hides, so
36
+ * without a handler to set `dismissed` the ✕ can't close the banner. Leave
37
+ * undefined for uncontrolled. */
38
+ dismissed?: boolean;
39
+ /** Initial dismissed state for the uncontrolled case (read once on mount). */
40
+ defaultDismissed?: boolean;
41
+ /** Fired when the user dismisses the banner (clicks ✕). Uncontrolled, the banner
42
+ * hides itself and this just notifies. Controlled, it's how you accept the
43
+ * request — set `dismissed` to `true` (or ignore to reject); pair it with
44
+ * `dismissed`, or the banner can't be closed. */
45
+ onDismiss?: () => void;
46
+ }
47
+ /**
48
+ * `<Banner>` — a full-width, dismissible message strip with a bottom border.
49
+ *
50
+ * A close cousin of `<Card>` with a small, centered horizontal payload:
51
+ * `message` leads, `children` sit in the middle, `actions` trail, and (when
52
+ * `closable`) a 40px-wide, full-height ✕ fills the right edge without shifting the centered group.
53
+ *
54
+ * A pure, stateless pass-through to `<a-banner>`: the element owns dismissal and
55
+ * visibility, so the wrapper holds no state and grabs no ref — it maps props to
56
+ * attributes (safe wherever the host DOM is reconciled, incl. off the UI thread).
57
+ *
58
+ * Uncontrolled (`defaultDismissed`): the element owns visibility and the ✕
59
+ * self-dismisses. Controlled (`dismissed` + `onDismiss`): the wrapper emits
60
+ * `state="open"|"closed"`; the element treats the attribute as the source of
61
+ * truth and the ✕ only *requests* dismissal. The element uses Anta's shared
62
+ * open/closed state contract under the hood — the wrapper presents it as the
63
+ * banner-natural `dismissed` (closed = dismissed). See STATEFUL-COMPONENTS.md.
64
+ *
65
+ * Requires `@antadesign/anta/elements` to be imported (client-side only) to
66
+ * register the underlying custom element.
67
+ *
68
+ * @example Basic usage
69
+ * ```tsx
70
+ * <Banner tone="info" message="A new release is available." actions={<Button size="small" label="Update" />} />
71
+ * ```
72
+ *
73
+ * @example Controlled
74
+ * ```tsx
75
+ * const [hidden, setHidden] = useState(false)
76
+ * <Banner message="Saved." dismissed={hidden} onDismiss={() => setHidden(true)} />
77
+ * ```
78
+ */
79
+ export declare const Banner: ({ message, actions, tone, round, closable, role, dismissed, defaultDismissed, onDismiss, className, style, children, ...rest }: BannerProps) => any;
@@ -0,0 +1,69 @@
1
+ import { jsx, jsxs } from "@antadesign/anta/jsx-runtime";
2
+ import { nativeStateChange, toneStyle, roundStyle, roundAttr } from "../anta_helpers";
3
+ import { Button } from "./Button";
4
+ const Banner = ({
5
+ message,
6
+ actions,
7
+ tone,
8
+ round,
9
+ closable,
10
+ role = "status",
11
+ dismissed,
12
+ defaultDismissed,
13
+ onDismiss,
14
+ className,
15
+ style,
16
+ children,
17
+ ...rest
18
+ }) => {
19
+ const controlled = dismissed != null;
20
+ const computedStyle = roundStyle(
21
+ round,
22
+ "--banner-round",
23
+ toneStyle(tone, "--banner-tone-source", style)
24
+ );
25
+ const messageNode = message == null ? null : typeof message === "string" || typeof message === "number" ? /* @__PURE__ */ jsx("a-banner-message", { slot: "message", children: message }) : /* @__PURE__ */ jsx("span", { slot: "message", style: { display: "contents" }, children: message });
26
+ return /* @__PURE__ */ jsxs(
27
+ "a-banner",
28
+ {
29
+ state: controlled ? dismissed ? "closed" : "open" : void 0,
30
+ "default-state": !controlled ? defaultDismissed ? "closed" : "open" : void 0,
31
+ tone: tone && tone !== "neutral" ? tone : void 0,
32
+ round: roundAttr(round),
33
+ role,
34
+ onstatechange: onDismiss ? (e) => {
35
+ const { detail, isOwn } = nativeStateChange(e);
36
+ if (isOwn && detail?.next === "closed") onDismiss();
37
+ } : void 0,
38
+ class: className,
39
+ style: computedStyle,
40
+ ...rest,
41
+ children: [
42
+ messageNode,
43
+ children,
44
+ actions != null && /* @__PURE__ */ jsx("span", { slot: "actions", style: { display: "contents" }, children: actions }),
45
+ closable !== false && // A real <a-button> (light DOM → fully styled, keyboard-focusable) in the
46
+ // element's `close` slot. It fires the bubbling `dismissrequest` event via
47
+ // a-button's global listener, so dismissing works even without framework
48
+ // hydration; the element turns that into a dismiss request.
49
+ // CONTRACT: `data-custom-event` below MUST match `DISMISS_TRIGGER` in the
50
+ // element (src/elements/a-banner.ts). Duplicated, not shared — importing
51
+ // the element module here would self-register it and break the
52
+ // wrapper/element decoupling. Rename in both places.
53
+ /* @__PURE__ */ jsx("span", { slot: "close", style: { display: "contents" }, children: /* @__PURE__ */ jsx(
54
+ Button,
55
+ {
56
+ priority: "quaternary",
57
+ icon: "x",
58
+ tone,
59
+ "aria-label": "Dismiss",
60
+ "data-custom-event": "dismissrequest"
61
+ }
62
+ ) })
63
+ ]
64
+ }
65
+ );
66
+ };
67
+ export {
68
+ Banner
69
+ };
@@ -227,6 +227,24 @@ export interface SelectCommonProps<V extends OptionValue = string> extends Omit<
227
227
  * `indicator` (`'check'` / `'radio'`) or `selection="multiple"` for the
228
228
  * semantics. Composes with `renderOption`. */
229
229
  renderIndicator?: (state: OptionState<V>) => React.ReactNode;
230
+ /** `multiple` only: spell the picks out in the count summary — `3 selected:
231
+ * A, B, C` (labels comma-joined) in place of the bare `3 selected`. Applies
232
+ * to the multi-count case only: `All` stays `All`, a single pick stays its
233
+ * own label, and an empty selection stays the `placeholder`. The list flows
234
+ * into the read-only field, so it ellipsizes at the field's width when long
235
+ * (`3 selected: Engineering, Des… `). `renderSummary` overrides this. */
236
+ verbose?: boolean;
237
+ /** `multiple` only: build the trigger's selection summary text yourself,
238
+ * replacing the built-in "`All` / one label / `N selected`" logic. Receives
239
+ * the resolved selected options (`selected.length` is the count) and runs only
240
+ * while something is selected — an empty selection still shows the
241
+ * `placeholder`. Return a **string**: it flows into the default trigger's
242
+ * read-only field, so a long summary ellipsizes at the field's width just
243
+ * like a long value (`Engineering, Design, … `). Return `undefined` to fall
244
+ * back to the default for that case (e.g. customize only the count, keeping
245
+ * the single-label case built-in). For rich content (chips, multiple nodes)
246
+ * use `renderTrigger`, which replaces the whole field. */
247
+ renderSummary?: (selected: SelectOption<V>[]) => string | undefined;
230
248
  /** Render your own trigger in place of the default field. Receives a
231
249
  * `TriggerState` (`open` / `value` / `selected` / `disabled` / `icon`) to drive
232
250
  * its look. **Return exactly one focusable element** (an Anta `Button`, say) —
@@ -62,6 +62,8 @@ const Select = (props) => {
62
62
  clearLabel = "Clear",
63
63
  renderOption,
64
64
  renderIndicator,
65
+ verbose,
66
+ renderSummary,
65
67
  renderTrigger,
66
68
  renderEmpty,
67
69
  className,
@@ -112,13 +114,22 @@ const Select = (props) => {
112
114
  const selectedValues = Array.isArray(currentRaw) ? currentRaw : currentRaw != null ? [currentRaw] : [];
113
115
  const isSelected = (v) => selectedValues.includes(v);
114
116
  const selectedOptions = selectedValues.map((v) => byValue.get(v)).filter(Boolean);
117
+ const allEnabledValues = allLeaves.filter((l) => !l.disabled).map((l) => l.opt.value);
118
+ const everythingSelected = multiple && allEnabledValues.length > 1 && allEnabledValues.every((v) => selectedValues.includes(v));
119
+ const labelOf = (o) => o.label ?? String(o.value);
115
120
  let display = "";
116
121
  if (multiple) {
117
- if (selectedOptions.length === 1) display = selectedOptions[0].label ?? String(selectedOptions[0].value);
118
- else if (selectedOptions.length > 1) display = `${selectedOptions.length} selected`;
122
+ const custom = selectedOptions.length > 0 ? renderSummary?.(selectedOptions) : void 0;
123
+ if (custom != null && custom !== "") display = custom;
124
+ else if (everythingSelected) display = "All";
125
+ else if (selectedOptions.length === 1) display = labelOf(selectedOptions[0]);
126
+ else if (selectedOptions.length > 1) {
127
+ const count = `${selectedOptions.length} selected`;
128
+ display = verbose ? `${count}: ${selectedOptions.map(labelOf).join(", ")}` : count;
129
+ }
119
130
  } else if (currentRaw != null) {
120
131
  const o = byValue.get(currentRaw);
121
- display = o ? o.label ?? String(o.value) : "";
132
+ display = o ? labelOf(o) : "";
122
133
  }
123
134
  const choose = (o, e) => {
124
135
  if (multiple) {
@@ -196,7 +196,7 @@ const SelectFaceted = (props) => {
196
196
  const applied = current[facet.key] ?? "";
197
197
  const draft = drafts[facet.key] ?? applied;
198
198
  const apply = () => {
199
- const next = draft.trim() || void 0;
199
+ const next = draft.trim() === "" ? void 0 : draft;
200
200
  if (next !== (applied || void 0)) setFacet(facet, next);
201
201
  };
202
202
  return (
@@ -312,7 +312,7 @@ const SelectFaceted = (props) => {
312
312
  searchable && rootQuery.trim() ? renderFlatResults() : facets.map((facet) => /* @__PURE__ */ jsxs(MenuItem, { submenu: true, icon: facet.icon, label: facet.label, children: [
313
313
  (() => {
314
314
  const s = summaryOf(facet);
315
- return s != null ? /* @__PURE__ */ jsx(Tag, { size: "small", tone: "brand", children: s }) : null;
315
+ return s != null ? /* @__PURE__ */ jsx(Tag, { size: "small", tone: "brand", children: /* @__PURE__ */ jsx("span", { className: styles.summary, children: s }) }) : null;
316
316
  })(),
317
317
  renderEditor(facet)
318
318
  ] }, facet.key)),
@@ -1 +1 @@
1
- .search{padding:var(--menu-padding, 4px)}.footer{padding:var(--menu-padding, 4px);border-top:1px solid var(--border-4)}
1
+ .search{padding:var(--menu-padding, 4px)}.summary{display:block;max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.footer{padding:var(--menu-padding, 4px);border-top:1px solid var(--border-4)}
@@ -0,0 +1,48 @@
1
+ import type { BaseProps } from "../general_types";
2
+ import { type Toaster as ToasterStore } from "../toaster";
3
+ export interface ToasterProps extends BaseProps {
4
+ /** The store this region renders. Omit to bind the default store driven by
5
+ * `Toaster.manager`; pass a `createToaster()` for an isolated region. */
6
+ toaster?: ToasterStore;
7
+ /** Accessible label for the region landmark.
8
+ * @defaultValue 'Notifications' */
9
+ label?: string;
10
+ }
11
+ /**
12
+ * `<Toaster>` — the mounted notification region. Keep one mounted somewhere in
13
+ * your app (a portal is fine); toasts are added imperatively through the store
14
+ * (`Toaster.manager`, or a `createToaster()` you pass as `toaster`). It
15
+ * subscribes to the store and renders each toast through the reconciler, so
16
+ * React/Preact owns the toast nodes.
17
+ *
18
+ * Announcement is opt-in **per toast** (`add(render, { politeness })`), not a
19
+ * blanket live region on the whole toaster.
20
+ *
21
+ * Requires `@antadesign/anta/elements` to be imported (client-side only) to
22
+ * register the underlying custom elements.
23
+ *
24
+ * @example
25
+ * ```tsx
26
+ * <Toaster /> // mount once, keep it mounted
27
+ *
28
+ * // A bare string is auto-wrapped in a dismissible Banner:
29
+ * Toaster.manager.add(() => 'Saved', { placement: 'bottom-right' })
30
+ *
31
+ * // Or toast a Banner yourself and wire its dismiss to the toast:
32
+ * Toaster.manager.add((id) => (
33
+ * <Banner tone="success" message="Saved" onDismiss={() => Toaster.manager.dismiss(id)} />
34
+ * ))
35
+ * ```
36
+ */
37
+ declare const ToasterImpl: ({ toaster, label, className, style, children: _children, ...rest }: ToasterProps) => any;
38
+ /**
39
+ * The `<Toaster>` component, carrying the default toast store as a static:
40
+ * `Toaster.manager.add(render, opts)`. A function-with-a-static (like
41
+ * `Component.displayName`) — no class component, which would couple to a renderer.
42
+ */
43
+ export declare const Toaster: typeof ToasterImpl & {
44
+ /** The default toast store, bound to a `<Toaster>` mounted with no `toaster`. */
45
+ readonly manager: ToasterStore;
46
+ };
47
+ export { createToaster } from "../toaster";
48
+ export type { Toaster as ToasterManager, ToastOptions, ToastPlacement, ToastContent, ToastRender, } from "../toaster";
@@ -0,0 +1,56 @@
1
+ import { jsx } from "@antadesign/anta/jsx-runtime";
2
+ import { useMemo, useSyncExternalStore } from "../jsx-runtime";
3
+ import { createToaster } from "../toaster";
4
+ import { Banner } from "./Banner";
5
+ const ToastItem = ({ entry, store }) => {
6
+ const content = useMemo(() => entry.render(entry.id), [entry.id, entry.rev]);
7
+ const isNode = typeof Node !== "undefined" && content instanceof Node;
8
+ const isText = typeof content === "string" || typeof content === "number";
9
+ const common = {
10
+ slot: entry.placement,
11
+ duration: entry.duration,
12
+ leaving: entry.leaving ? "" : void 0,
13
+ rev: entry.rev,
14
+ // Opt-in per-toast announcement. role=status/alert (not a bare aria-live) so
15
+ // it announces when inserted — a plain aria-live region must pre-exist before
16
+ // its content changes, which a freshly-added toast can't satisfy. Omitted (no
17
+ // announcement) unless requested.
18
+ role: entry.politeness === "assertive" ? "alert" : entry.politeness === "polite" ? "status" : void 0,
19
+ // Fires after the exit animation; that's when the entry actually leaves.
20
+ ondismiss: () => store.remove(entry.id)
21
+ };
22
+ if (isNode) return /* @__PURE__ */ jsx("a-toast", { ...common, content });
23
+ if (isText)
24
+ return /* @__PURE__ */ jsx("a-toast", { ...common, children: /* @__PURE__ */ jsx(
25
+ Banner,
26
+ {
27
+ round: true,
28
+ message: content,
29
+ dismissed: false,
30
+ onDismiss: () => store.dismiss(entry.id)
31
+ }
32
+ ) });
33
+ return /* @__PURE__ */ jsx("a-toast", { ...common, children: content });
34
+ };
35
+ const ToasterImpl = ({
36
+ toaster,
37
+ label = "Notifications",
38
+ className,
39
+ style,
40
+ // The region's children come from the store, not from JSX.
41
+ children: _children,
42
+ ...rest
43
+ }) => {
44
+ const store = toaster ?? getDefaultStore();
45
+ const entries = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getServerSnapshot);
46
+ return /* @__PURE__ */ jsx("a-toaster", { role: "region", "aria-label": label, class: className, style, ...rest, children: entries.map((entry) => /* @__PURE__ */ jsx(ToastItem, { entry, store }, entry.id)) });
47
+ };
48
+ let defaultStore;
49
+ const getDefaultStore = () => defaultStore ??= createToaster();
50
+ const Toaster = ToasterImpl;
51
+ Object.defineProperty(Toaster, "manager", { get: getDefaultStore, enumerable: true });
52
+ import { createToaster as createToaster2 } from "../toaster";
53
+ export {
54
+ Toaster,
55
+ createToaster2 as createToaster
56
+ };
@@ -0,0 +1 @@
1
+ @layer anta{a-banner{--banner-bg: var(--bg-4);--banner-border: var(--border-4);--banner-text: var(--text-2);display:flex;align-items:center;justify-content:center;flex-wrap:wrap;gap:4px 1ch;position:relative;box-sizing:border-box;width:100%;min-height:40px;padding:6px 52px;color:var(--banner-text);background:var(--banner-bg);border:0 solid var(--banner-border);border-radius:0;overflow:clip;overflow-clip-margin:3px;interpolate-size:allow-keywords;transition:opacity .16s ease,block-size .2s ease,padding-block .2s ease,border-block-width .2s ease,display .2s allow-discrete}a-banner[round]{--banner-round: attr(round type(<length>));border-radius:var(--banner-round, 999px)}a-banner [slot=close] a-button,a-banner a-button[slot=close]{height:100%;width:40px}a-banner-message{font-size:14px;line-height:20px;font-weight:475;text-align:center}a-banner[tone=brand]{--banner-bg: var(--bg-4-brand);--banner-border: var(--border-4-brand);--banner-text: var(--text-2-brand)}a-banner[tone=info]{--banner-bg: var(--bg-4-info);--banner-border: var(--border-4-info);--banner-text: var(--text-2-info)}a-banner[tone=success]{--banner-bg: var(--bg-4-success);--banner-border: var(--border-4-success);--banner-text: var(--text-2-success)}a-banner[tone=warning]{--banner-bg: var(--bg-4-warning);--banner-border: var(--border-4-warning);--banner-text: var(--text-2-warning)}a-banner[tone=critical]{--banner-bg: var(--bg-4-critical);--banner-border: var(--border-4-critical);--banner-text: var(--text-2-critical)}a-banner[tone]:not([tone=""],[tone=neutral],[tone=brand],[tone=info],[tone=success],[tone=warning],[tone=critical]){--banner-tone-source: attr(tone type(<color>), currentColor);--banner-text: oklch(from var(--banner-tone-source) .5 .125 h);--banner-bg: oklch(from var(--banner-tone-source) .955 .02 h);--banner-border: oklch(from var(--banner-tone-source) .9 .04 h);.dark &{--banner-text: oklch(from var(--banner-tone-source) .77 .11 h);--banner-bg: oklch(from var(--banner-tone-source) .185 .05 h);--banner-border: oklch(from var(--banner-tone-source) .26 .06 h)}}a-banner:not(:defined){opacity:0}a-banner[state=closed],a-banner[default-state=closed],a-banner:state(closed){display:none;block-size:0;min-block-size:0;padding-block:0;border-block-width:0;opacity:0}@media(prefers-reduced-motion:reduce){a-banner{transition:none}}}
@@ -0,0 +1,10 @@
1
+ import { HTMLElementBase } from '../anta_helpers';
2
+ import './a-banner.css';
3
+ export declare class ABannerElement extends HTMLElementBase {
4
+ #private;
5
+ static observedAttributes: string[];
6
+ constructor();
7
+ connectedCallback(): void;
8
+ attributeChangedCallback(name: string): void;
9
+ }
10
+ export declare function register_a_banner(): void;
@@ -0,0 +1,141 @@
1
+ import { HTMLElementBase, parseOpenState } from "../anta_helpers";
2
+ import "./a-banner.css";
3
+ const DISMISS_TRIGGER = "dismissrequest";
4
+ const DISMISS_ATTR = "data-banner-dismiss";
5
+ const SHADOW_STYLE = `
6
+ slot[name="message"], slot:not([name]) { display: contents; }
7
+
8
+ slot[name="actions"] {
9
+ display: flex;
10
+ align-items: center;
11
+ gap: 4px;
12
+ flex-shrink: 0;
13
+ }
14
+
15
+ slot[name="close"] {
16
+ position: absolute;
17
+ inset-inline-end: 0;
18
+ inset-block: 0;
19
+ display: grid;
20
+ place-items: center;
21
+ opacity: 0.65;
22
+ transition: opacity 150ms ease;
23
+ }
24
+ slot[name="close"]:hover,
25
+ slot[name="close"]:focus-within {
26
+ opacity: 1;
27
+ }
28
+ `;
29
+ const parseState = parseOpenState;
30
+ class ABannerElement extends HTMLElementBase {
31
+ static observedAttributes = ["state"];
32
+ // Visibility is held here as a custom state (`:state(open)` / `:state(closed)`),
33
+ // NOT a host attribute — declarative-DOM safe (no host mutation, no reconcile
34
+ // churn). This is the ONLY declarative-safe self-hide channel, so a runtime
35
+ // (uncontrolled) ✕ dismiss needs CustomStateSet + the CSS `:state()` selector
36
+ // (Chrome 90+, Safari 17.4+, Firefox 126+); below that the ✕ can't self-hide (an
37
+ // attribute fallback would violate the no-host-mutation rule). Controlled mode
38
+ // degrades everywhere — the `state="closed"` attribute the wrapper sets has the
39
+ // `[state="closed"]` CSS fallback, which needs no custom-state support.
40
+ #internals;
41
+ // `default-state` seeds the uncontrolled intent ONCE, on the first connect; a
42
+ // re-parent (disconnect → reconnect) then keeps the applied state (which lives
43
+ // off-DOM in #internals and survives the move) instead of re-seeding `open` over a
44
+ // banner the user already dismissed. Mirrors a-dialog's #seeded.
45
+ #seeded = false;
46
+ constructor() {
47
+ super();
48
+ this.#internals = typeof this.attachInternals === "function" ? this.attachInternals() : void 0;
49
+ const shadow = this.attachShadow({ mode: "open" });
50
+ const style = document.createElement("style");
51
+ style.textContent = SHADOW_STYLE;
52
+ const messageSlot = document.createElement("slot");
53
+ messageSlot.name = "message";
54
+ messageSlot.setAttribute("part", "message");
55
+ const contentSlot = document.createElement("slot");
56
+ contentSlot.setAttribute("part", "content");
57
+ const actionsSlot = document.createElement("slot");
58
+ actionsSlot.name = "actions";
59
+ actionsSlot.setAttribute("part", "actions");
60
+ const closeSlot = document.createElement("slot");
61
+ closeSlot.name = "close";
62
+ closeSlot.setAttribute("part", "close");
63
+ shadow.append(style, messageSlot, contentSlot, actionsSlot, closeSlot);
64
+ this.addEventListener(DISMISS_TRIGGER, (e) => {
65
+ e.stopPropagation();
66
+ this.#requestDismiss();
67
+ });
68
+ this.addEventListener("click", (e) => {
69
+ const target = e.target;
70
+ if (!(target instanceof Element)) return;
71
+ const trigger = target.closest(`[${DISMISS_ATTR}]`);
72
+ if (trigger && trigger.closest("a-banner") === this) this.#requestDismiss();
73
+ });
74
+ }
75
+ /** Controlled mode: the `state` attribute is present and owns visibility. */
76
+ get #controlled() {
77
+ return this.hasAttribute("state");
78
+ }
79
+ /** The currently *applied* state (read from the custom state — the source of
80
+ * truth for what's painted). Reads open until the element has applied a state.
81
+ * Both `?.` guard engines with ElementInternals but no CustomStateSet (`.states`
82
+ * undefined), where reading it would otherwise throw inside the ✕ click. */
83
+ get #current() {
84
+ return this.#internals?.states?.has("closed") ? "closed" : "open";
85
+ }
86
+ connectedCallback() {
87
+ if (this.#controlled) {
88
+ this.#applyState(parseState(this.getAttribute("state")));
89
+ } else if (!this.#seeded) {
90
+ this.#applyState(this.#seedState());
91
+ this.#seeded = true;
92
+ }
93
+ }
94
+ /** The uncontrolled initial state. A banner is shown by DEFAULT, so `open` unless
95
+ * `default-state="closed"` is explicit. (Unlike a-dialog / a-expander, whose
96
+ * absent default-state means closed — a banner is the inverse, so it can't share
97
+ * `parseState` here. A hand-authored `<a-banner>` with no attributes shows.) */
98
+ #seedState() {
99
+ return this.getAttribute("default-state") === "closed" ? "closed" : "open";
100
+ }
101
+ attributeChangedCallback(name) {
102
+ if (name === "state" && this.#controlled) this.#applyState(parseState(this.getAttribute("state")));
103
+ }
104
+ /** Mirror the state to `:state(open)` / `:state(closed)` (idempotent). The
105
+ * external sheet collapses the host on `:state(closed)`. Off-DOM only. */
106
+ #applyState(state) {
107
+ if (!this.#internals) return;
108
+ try {
109
+ if (state === "closed") {
110
+ this.#internals.states.add("closed");
111
+ this.#internals.states.delete("open");
112
+ } else {
113
+ this.#internals.states.add("open");
114
+ this.#internals.states.delete("closed");
115
+ }
116
+ } catch {
117
+ }
118
+ }
119
+ /** Announce a requested dismiss (cancelable, before applying), then — uncontrolled
120
+ * and un-vetoed — apply it. Controlled: never self-apply; the consumer answers
121
+ * via `state`. See STATEFUL-COMPONENTS.md. */
122
+ #requestDismiss() {
123
+ if (this.#current === "closed") return;
124
+ const ok = this.dispatchEvent(
125
+ new CustomEvent("statechange", { cancelable: true, detail: { next: "closed", prev: "open" } })
126
+ );
127
+ if (this.#controlled) return;
128
+ if (ok) this.#applyState("closed");
129
+ }
130
+ }
131
+ function register_a_banner() {
132
+ if (typeof customElements === "undefined") return;
133
+ if (!customElements.get("a-banner")) {
134
+ customElements.define("a-banner", ABannerElement);
135
+ }
136
+ }
137
+ register_a_banner();
138
+ export {
139
+ ABannerElement,
140
+ register_a_banner
141
+ };
@@ -31,7 +31,12 @@ function ensureMenuItemKeyListener(doc) {
31
31
  if (doc.hasKeyListenerForAMenuItem) return;
32
32
  installKeyActivation(doc, {
33
33
  keys: ["Enter", " "],
34
- resolve: (t) => t?.closest?.(MENU_ITEM_SELECTOR),
34
+ resolve: (t) => {
35
+ const el = t;
36
+ if (el?.closest?.('input, textarea, select, a-input, a-input-time, [contenteditable]:not([contenteditable="false"])'))
37
+ return null;
38
+ return el?.closest?.(MENU_ITEM_SELECTOR);
39
+ },
35
40
  blocked: (el) => el.hasAttribute("disabled"),
36
41
  activate: (el) => el.click()
37
42
  });
@@ -1 +1 @@
1
- @layer anta{a-menu:not(:defined){display:none}a-menu{--menu-bg: color-mix(in oklch, var(--bg-1) 90%, transparent);--menu-radius: var(--radius-md, 8px);--menu-border: 1px solid var(--border-3);--menu-shadow: 0 2px 6px color-mix(in oklch, var(--text-1) 8%, transparent), 0 8px 24px color-mix(in oklch, var(--text-1) 10%, transparent);--menu-padding: 4px;--menu-min-width: 88px;--menu-backdrop-filter: blur(20px)}a-menu[round]{--menu-radius: attr(round type(<length>), 20px)}.dark a-menu{--menu-bg: color-mix(in oklch, var(--bg-1) 78%, transparent);--menu-shadow: 0 2px 8px color-mix(in oklch, black 50%, transparent), 0 12px 32px color-mix(in oklch, black 40%, transparent), inset 0 0 0 1px color-mix(in oklch, white 8%, transparent)}}
1
+ @layer anta{a-menu:not(:defined){display:none}a-menu{--menu-bg: color-mix(in oklch, var(--bg-1) 90%, transparent);--menu-radius: var(--radius-md, 8px);--menu-border: 1px solid var(--border-3);--menu-shadow: 0 2px 6px color-mix(in oklch, var(--text-1) 8%, transparent), 0 8px 24px color-mix(in oklch, var(--text-1) 10%, transparent);--menu-padding: 4px;--menu-min-width: 88px;--menu-backdrop-filter: blur(20px);--menu-width-duration: .16s}@media(prefers-reduced-motion:reduce){a-menu{--menu-width-duration: 0}}a-menu[round]{--menu-radius: attr(round type(<length>), 20px)}.dark a-menu{--menu-bg: color-mix(in oklch, var(--bg-1) 78%, transparent);--menu-shadow: 0 2px 8px color-mix(in oklch, black 50%, transparent), 0 12px 32px color-mix(in oklch, black 40%, transparent), inset 0 0 0 1px color-mix(in oklch, white 8%, transparent)}}
@@ -216,6 +216,7 @@ class AMenuElement extends HTMLElementBase {
216
216
  transition:
217
217
  opacity 140ms ease-out,
218
218
  translate 140ms ease-out,
219
+ width var(--menu-width-duration, 160ms) ease,
219
220
  display 140ms allow-discrete,
220
221
  overlay 140ms allow-discrete;
221
222
  }
@@ -227,6 +228,7 @@ class AMenuElement extends HTMLElementBase {
227
228
  transition:
228
229
  opacity 100ms ease-out,
229
230
  translate 100ms ease-out,
231
+ width var(--menu-width-duration, 160ms) ease,
230
232
  display 100ms allow-discrete,
231
233
  overlay 100ms allow-discrete;
232
234
  }
@@ -0,0 +1 @@
1
+ @property --toast-remaining{syntax: "<number>"; inherits: true; initial-value: 1;}@layer anta{a-toast{--toast-dur: .22s}a-toast:not(:defined){display:none}}
@@ -0,0 +1,70 @@
1
+ import { HTMLElementBase } from '../anta_helpers';
2
+ import './a-toast.css';
3
+ /**
4
+ * `<a-toast>` — one item inside an `<a-toaster>`. A style-neutral container around
5
+ * slotted content (a `Banner`, `Card`, `Sticker`, a string, or a raw DOM node):
6
+ * it owns the enter / exit animation and the auto-dismiss timer (paused while
7
+ * hovered or focused), nothing else. No chrome of its own — no ✕, no surface; the
8
+ * toasted content brings its own look and its own dismiss affordance. Its `slot`
9
+ * attribute (`slot="bottom-right"`, …) routes it to a placement zone.
10
+ *
11
+ * Content arrives one of two ways: as slotted light-DOM children (a string / JSX
12
+ * the `<Toaster>` renders through the reconciler), or as a live DOM node set on
13
+ * the `content` property (the wrapper's DOM-node branch), which the element slots
14
+ * into its own light DOM. Either way it projects through the shadow `<slot>`.
15
+ *
16
+ * ## Time left — `--toast-remaining`
17
+ *
18
+ * While the auto-dismiss timer runs, the element animates `--toast-remaining` from
19
+ * `1` to `0` on the slot (paused in lockstep with the timer on hover / focus).
20
+ * It's a registered `@property` (see `a-toast.css`) with `inherits: true`, so
21
+ * slotted content inherits the live value and can draw a countdown with one CSS
22
+ * rule — `transform: scaleX(var(--toast-remaining))` — with no timer of its own
23
+ * and no re-render. Display only: the real dismiss is the JS timer, so it degrades
24
+ * to a full bar where custom-property animation is unsupported. Sticky
25
+ * (`duration="0"`) runs no countdown; the var stays `1`.
26
+ *
27
+ * ## Dismiss from an action — `data-toast-dismiss`
28
+ *
29
+ * Any element inside the toast carrying `data-toast-dismiss` dismisses it on click
30
+ * — the same convention as `<a-banner>`'s `data-banner-dismiss` and `<a-dialog>`'s
31
+ * `data-dialog-close`. It's a click listener (a light-DOM READ, declarative-safe),
32
+ * so it catches any activated control — native `<button>`, `<a>`, `<a-button>`,
33
+ * `<a-menu-item>` — and keyboard activation for free. A toasted `<Banner>` instead
34
+ * dismisses through its own `onDismiss` (wire it to `manager.dismiss(id)`); its
35
+ * built-in ✕ and any `data-banner-dismiss` action ride that channel.
36
+ *
37
+ * It **never removes itself**. Dismissal — from the timer, a `data-toast-dismiss`
38
+ * control, or the `leaving` attribute the wrapper sets for a programmatic
39
+ * `dismiss` — fades the host out (an off-DOM `:state(leaving)`), then emits a
40
+ * bubbling **`dismiss`**; the owner removes the node on that event. So the element
41
+ * writes only its own shadow + internals, reads its own attributes (and, read-only,
42
+ * the light DOM for the dismiss trigger), appends only its own (wrapper-supplied)
43
+ * content node, and dispatches events.
44
+ *
45
+ * Shadow structure — the host IS the box (no wrappers):
46
+ *
47
+ * :host ← the fade/slide layer
48
+ * <slot> ← the content
49
+ */
50
+ export declare class AToastElement extends HTMLElementBase {
51
+ #private;
52
+ static observedAttributes: string[];
53
+ constructor();
54
+ connectedCallback(): void;
55
+ disconnectedCallback(): void;
56
+ attributeChangedCallback(name: string): void;
57
+ /** A live DOM node to show as the content (set by the wrapper's DOM-node
58
+ * branch). Paired getter/setter so React 19's property assignment works. */
59
+ get content(): Node | null;
60
+ set content(node: Node | null);
61
+ /** Restart the auto-dismiss timer from the current `duration` (an in-place
62
+ * update). If the toast is currently paused, the fresh time is banked and the
63
+ * countdown stays paused until hover / focus releases. */
64
+ restart(): void;
65
+ /** Play the exit (shadow only), then emit a bubbling `dismiss`. The owner
66
+ * removes the node on that event — the element never removes itself.
67
+ * Idempotent. */
68
+ dismiss(): void;
69
+ }
70
+ export declare function register_a_toast(): void;