@signal9/era-ui 30.2.0 → 30.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,15 @@
1
+ ## [30.2.2](https://github.com/sig-nine/era-ui/compare/v30.2.1...v30.2.2) (2026-08-16)
2
+
3
+ ### Bug Fixes
4
+
5
+ * **provider:** portalled content keeps its axes with no trigger rendered ([ed15a44](https://github.com/sig-nine/era-ui/commit/ed15a445eccafa582907e4bf08d9735edd9140cf))
6
+
7
+ ## [30.2.1](https://github.com/sig-nine/era-ui/compare/v30.2.0...v30.2.1) (2026-08-16)
8
+
9
+ ### Bug Fixes
10
+
11
+ * **select:** the multi-value chips no longer nest a button in the trigger ([c7ff9a2](https://github.com/sig-nine/era-ui/commit/c7ff9a27469b4f5ef2059e4616af38c2bdede8f7))
12
+
1
13
  ## [30.2.0](https://github.com/sig-nine/era-ui/compare/v30.1.4...v30.2.0) (2026-08-16)
2
14
 
3
15
  ### Features
@@ -23,8 +23,11 @@
23
23
 
24
24
  // Capture on OPEN, not on mount: bits-ui mounts content eagerly and toggles
25
25
  // it, so a mount-time read runs once at page load and never again.
26
+ // activeElement is the FALLBACK anchor, used only when no Trigger rendered —
27
+ // see AxisRelay.refresh(). It is read here, at the instant `open` turns true,
28
+ // because bits-ui moves focus into the panel a moment later.
26
29
  $effect(() => {
27
- if (open) relay.refresh();
30
+ if (open) relay.refresh(document.activeElement);
28
31
  });
29
32
  </script>
30
33
 
@@ -61,13 +61,14 @@
61
61
  import type { Snippet } from 'svelte';
62
62
  import type { HTMLAttributes } from 'svelte/elements';
63
63
  import { cn } from '../../utils/index.js';
64
- import { Button } from '../button';
64
+ import { Button, buttonVariants } from '../button';
65
65
  import X from '@lucide/svelte/icons/x';
66
66
 
67
67
  let {
68
68
  ref = $bindable(null),
69
69
  size = 'chip',
70
70
  ondismiss,
71
+ dismissAs = 'button',
71
72
  lead,
72
73
  children,
73
74
  class: className,
@@ -78,6 +79,27 @@
78
79
  * that nests inside an md container (a select trigger, a button). */
79
80
  size?: 'chip' | 'pill';
80
81
  ondismiss?: () => void;
82
+ /**
83
+ * What the dismiss control RENDERS AS. `button` (the default) is right
84
+ * for a free-standing chip. Use `span` when the chip lives inside
85
+ * another control.
86
+ *
87
+ * A <button> inside a <button> is not merely bad manners: it is invalid,
88
+ * and the HTML parser repairs it by CLOSING the outer control before the
89
+ * inner one. So the server's markup and the client's DOM disagree, Svelte
90
+ * discards the page's hydration and re-renders from scratch, and the
91
+ * chips briefly sit outside the trigger they belong to. era's own
92
+ * multi-select shipped exactly that (a Chip with `ondismiss` inside
93
+ * Select.Trigger) and logged node_invalid_placement_ssr on every load.
94
+ *
95
+ * `span` also drops the control from the accessibility tree rather than
96
+ * announcing a button inside a button — which is the honest description
97
+ * of what it is there: a pointer shortcut. The accessible way to remove
98
+ * a selection is the listbox itself, which is unaffected. Keyboard focus
99
+ * order is unchanged, because a nested button was never reachable in a
100
+ * sensible order anyway.
101
+ */
102
+ dismissAs?: 'button' | 'span';
81
103
  /** Icon rendered BEFORE the label, outside it. Use this rather than putting
82
104
  * an icon in `children`: the label then gets its own box, which is the only
83
105
  * place era's ink centring can reach. */
@@ -112,16 +134,36 @@
112
134
  In the sm chip it nests; in the xxs chip the same button is the flush
113
135
  end-cap, so only its OUTER corners round — squaring the inner pair is
114
136
  what reads as "segment of this pill" rather than "pill on a pill". -->
115
- <Button
116
- icon
117
- size="pill"
118
- tone="destructive"
119
- class={size === 'pill' ? 'rounded-l-none' : undefined}
120
- onclick={ondismiss}
121
- onpointerdown={(e: PointerEvent) => e.stopPropagation()}
122
- onpointerup={(e: PointerEvent) => e.stopPropagation()}
123
- >
124
- <X />
125
- </Button>
137
+ {#if dismissAs === 'span'}
138
+ <!-- Same classes, no <button> — see the `dismissAs` prop. era-interactive
139
+ is in buttonVariants' base, so the focus/disabled recipe comes with
140
+ it; what a span cannot inherit is the pointer cursor a button gets
141
+ for free. -->
142
+ <span
143
+ aria-hidden="true"
144
+ class={cn(
145
+ buttonVariants({ icon: true, size: 'pill', tone: 'destructive' }),
146
+ 'cursor-pointer',
147
+ size === 'pill' && 'rounded-l-none'
148
+ )}
149
+ onclick={ondismiss}
150
+ onpointerdown={(e: PointerEvent) => e.stopPropagation()}
151
+ onpointerup={(e: PointerEvent) => e.stopPropagation()}
152
+ >
153
+ <X />
154
+ </span>
155
+ {:else}
156
+ <Button
157
+ icon
158
+ size="pill"
159
+ tone="destructive"
160
+ class={size === 'pill' ? 'rounded-l-none' : undefined}
161
+ onclick={ondismiss}
162
+ onpointerdown={(e: PointerEvent) => e.stopPropagation()}
163
+ onpointerup={(e: PointerEvent) => e.stopPropagation()}
164
+ >
165
+ <X />
166
+ </Button>
167
+ {/if}
126
168
  {/if}
127
169
  </div>
@@ -34,6 +34,27 @@ type $$ComponentProps = HTMLAttributes<HTMLDivElement> & {
34
34
  * that nests inside an md container (a select trigger, a button). */
35
35
  size?: 'chip' | 'pill';
36
36
  ondismiss?: () => void;
37
+ /**
38
+ * What the dismiss control RENDERS AS. `button` (the default) is right
39
+ * for a free-standing chip. Use `span` when the chip lives inside
40
+ * another control.
41
+ *
42
+ * A <button> inside a <button> is not merely bad manners: it is invalid,
43
+ * and the HTML parser repairs it by CLOSING the outer control before the
44
+ * inner one. So the server's markup and the client's DOM disagree, Svelte
45
+ * discards the page's hydration and re-renders from scratch, and the
46
+ * chips briefly sit outside the trigger they belong to. era's own
47
+ * multi-select shipped exactly that (a Chip with `ondismiss` inside
48
+ * Select.Trigger) and logged node_invalid_placement_ssr on every load.
49
+ *
50
+ * `span` also drops the control from the accessibility tree rather than
51
+ * announcing a button inside a button — which is the honest description
52
+ * of what it is there: a pointer shortcut. The accessible way to remove
53
+ * a selection is the listbox itself, which is unaffected. Keyboard focus
54
+ * order is unchanged, because a nested button was never reachable in a
55
+ * sensible order anyway.
56
+ */
57
+ dismissAs?: 'button' | 'span';
37
58
  /** Icon rendered BEFORE the label, outside it. Use this rather than putting
38
59
  * an icon in `children`: the label then gets its own box, which is the only
39
60
  * place era's ink centring can reach. */
@@ -23,8 +23,11 @@
23
23
 
24
24
  // Capture on OPEN, not on mount: bits-ui mounts content eagerly and toggles
25
25
  // it, so a mount-time read runs once at page load and never again.
26
+ // activeElement is the FALLBACK anchor, used only when no Trigger rendered —
27
+ // see AxisRelay.refresh(). It is read here, at the instant `open` turns true,
28
+ // because bits-ui moves focus into the panel a moment later.
26
29
  $effect(() => {
27
- if (open) relay.refresh();
30
+ if (open) relay.refresh(document.activeElement);
28
31
  });
29
32
  </script>
30
33
 
@@ -32,9 +32,15 @@
32
32
  * before a consumer has scoped anything, and never again. Reading when `open`
33
33
  * turns true is the moment the answer is actually needed, and it re-reads on
34
34
  * every open, so an axis that changed in between is picked up.
35
+ *
36
+ * `document.activeElement` is the FALLBACK anchor, used only when no Trigger
37
+ * rendered — see AxisRelay.refresh() for why that case exists and what it
38
+ * costs. It is read HERE, at the instant `open` turns true, because bits-ui
39
+ * moves focus into the panel a moment later and the answer would be the
40
+ * panel itself.
35
41
  */
36
42
  $effect(() => {
37
- if (open) relay.refresh();
43
+ if (open) relay.refresh(document.activeElement);
38
44
  });
39
45
  </script>
40
46
 
@@ -23,8 +23,11 @@
23
23
 
24
24
  // Capture on OPEN, not on mount: bits-ui mounts content eagerly and toggles
25
25
  // it, so a mount-time read runs once at page load and never again.
26
+ // activeElement is the FALLBACK anchor, used only when no Trigger rendered —
27
+ // see AxisRelay.refresh(). It is read here, at the instant `open` turns true,
28
+ // because bits-ui moves focus into the panel a moment later.
26
29
  $effect(() => {
27
- if (open) relay.refresh();
30
+ if (open) relay.refresh(document.activeElement);
28
31
  });
29
32
  </script>
30
33
 
@@ -23,8 +23,11 @@
23
23
 
24
24
  // Capture on OPEN, not on mount: bits-ui mounts content eagerly and toggles
25
25
  // it, so a mount-time read runs once at page load and never again.
26
+ // activeElement is the FALLBACK anchor, used only when no Trigger rendered —
27
+ // see AxisRelay.refresh(). It is read here, at the instant `open` turns true,
28
+ // because bits-ui moves focus into the panel a moment later.
26
29
  $effect(() => {
27
- if (open) relay.refresh();
30
+ if (open) relay.refresh(document.activeElement);
28
31
  });
29
32
  </script>
30
33
 
@@ -25,7 +25,7 @@
25
25
  relay.register(ref);
26
26
  });
27
27
  $effect(() => {
28
- if (value) relay.refresh();
28
+ if (value) relay.refresh(document.activeElement);
29
29
  });
30
30
  </script>
31
31
 
@@ -20,7 +20,7 @@ export declare class AxisRelay {
20
20
  * without touching surface, and closest() answers each question separately,
21
21
  * which is what makes the axes orthogonal in the first place.
22
22
  */
23
- refresh(): void;
23
+ refresh(fallback?: Element | null): void;
24
24
  /** The class string portalled content should carry. */
25
25
  get schemeClass(): string;
26
26
  }
@@ -64,8 +64,34 @@ export class AxisRelay {
64
64
  * without touching surface, and closest() answers each question separately,
65
65
  * which is what makes the axes orthogonal in the first place.
66
66
  */
67
- refresh() {
68
- const el = this.#anchor;
67
+ refresh(fallback) {
68
+ /*
69
+ * NO TRIGGER, NO ANCHOR — the gap this fallback closes.
70
+ *
71
+ * A dialog driven purely by `bind:open` renders no Trigger, so there is
72
+ * no node left in the subtree to read from and the content inherited
73
+ * from :root. That is not an edge case: driving a confirm dialog from
74
+ * state is the ordinary way to write one, and it is why a consumer went
75
+ * on restating `data-font` on every Dialog.Content long after the relay
76
+ * was supposed to have made that unnecessary. Measured before this: a
77
+ * dialog opened from a button inside a `data-font="serif"` section came
78
+ * up with data-font null and font-family monospace.
79
+ *
80
+ * The fallback is WHERE THE USER WAS when it opened — document.
81
+ * activeElement, passed in by the Root at the instant `open` turns
82
+ * true, before bits-ui moves focus into the panel. A state-driven
83
+ * dialog is nearly always opened by something the user just clicked,
84
+ * and that something sits exactly where the dialog logically belongs.
85
+ *
86
+ * <body> is not an anchor: with nothing focused there is nothing to
87
+ * learn, and reading from it would just re-derive :root the long way.
88
+ * A dialog opened by a timer while focus is elsewhere therefore keeps
89
+ * the old behaviour rather than picking up an unrelated subtree.
90
+ */
91
+ const el = this.#anchor ??
92
+ (fallback && fallback !== document.body && fallback !== document.documentElement
93
+ ? fallback
94
+ : null);
69
95
  if (!el?.closest)
70
96
  return;
71
97
  // The reading itself lives in axes-at.ts, runes-free, because the notes
@@ -56,6 +56,7 @@
56
56
  {#each sel.selected as item (item.value)}
57
57
  <Chip
58
58
  size="pill"
59
+ dismissAs="span"
59
60
  ondismiss={snippet.disabled
60
61
  ? undefined
61
62
  : () =>
@@ -23,8 +23,11 @@
23
23
 
24
24
  // Capture on OPEN, not on mount: bits-ui mounts content eagerly and toggles
25
25
  // it, so a mount-time read runs once at page load and never again.
26
+ // activeElement is the FALLBACK anchor, used only when no Trigger rendered —
27
+ // see AxisRelay.refresh(). It is read here, at the instant `open` turns true,
28
+ // because bits-ui moves focus into the panel a moment later.
26
29
  $effect(() => {
27
- if (open) relay.refresh();
30
+ if (open) relay.refresh(document.activeElement);
28
31
  });
29
32
  </script>
30
33
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@signal9/era-ui",
3
- "version": "30.2.0",
3
+ "version": "30.2.2",
4
4
  "scripts": {
5
5
  "dev": "vite dev --host",
6
6
  "build": "vite build && npm run prepack",