@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 +12 -0
- package/dist/ui/alert-dialog/alert-dialog-root.svelte +4 -1
- package/dist/ui/chip/chip.svelte +54 -12
- package/dist/ui/chip/chip.svelte.d.ts +21 -0
- package/dist/ui/context-menu/context-menu-root.svelte +4 -1
- package/dist/ui/dialog/dialog-root.svelte +7 -1
- package/dist/ui/dropdown-menu/dropdown-menu-root.svelte +4 -1
- package/dist/ui/link-preview/link-preview-root.svelte +4 -1
- package/dist/ui/menubar/menubar-root.svelte +1 -1
- package/dist/ui/provider/axis-relay.svelte.d.ts +1 -1
- package/dist/ui/provider/axis-relay.svelte.js +28 -2
- package/dist/ui/select/select-value.svelte +1 -0
- package/dist/ui/tooltip/tooltip-root.svelte +4 -1
- package/package.json +1 -1
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
|
|
package/dist/ui/chip/chip.svelte
CHANGED
|
@@ -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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
|
|
@@ -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
|
-
|
|
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
|
|
@@ -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
|
|