@signal9/era-ui 30.1.3 → 30.1.4

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,9 @@
1
+ ## [30.1.4](https://github.com/sig-nine/era-ui/compare/v30.1.3...v30.1.4) (2026-08-16)
2
+
3
+ ### Bug Fixes
4
+
5
+ * **button:** loading no longer changes the control's width ([7931edb](https://github.com/sig-nine/era-ui/commit/7931edbdb7cbcabf496586983836fc8bf1832e42))
6
+
1
7
  ## [30.1.3](https://github.com/sig-nine/era-ui/compare/v30.1.2...v30.1.3) (2026-08-16)
2
8
 
3
9
  ### Bug Fixes
@@ -28,9 +28,25 @@
28
28
  active?: boolean;
29
29
  icon?: boolean;
30
30
  /**
31
- * When true, disables the button, sets `aria-busy`, and prepends a
32
- * spinner. The existing label stays rendered so focus, click targets,
33
- * and screen-reader accessible names don't change shape mid-flight.
31
+ * In flight: disables the button, sets `aria-busy`, and puts a spinner
32
+ * over the label.
33
+ *
34
+ * THE GEOMETRY DOES NOT MOVE. That is the requirement, not a detail. A
35
+ * button is loading because it was just CLICKED, so anything `loading`
36
+ * changes about the box moves the page under the pointer that is still
37
+ * on it — and a spinner PREPENDED to the label (what this used to do)
38
+ * costs an icon's width plus a gap, measured on a wrapping header row
39
+ * that reflowed everything beside it. A consumer hit exactly that and
40
+ * stopped using the prop, hand-rolling `disabled` + `aria-busy`
41
+ * instead, which is the report that produced this note.
42
+ *
43
+ * So the spinner is absolutely positioned and takes no part in the
44
+ * flex layout, and the label goes to `opacity-0` rather than being
45
+ * unmounted: the box that holds the width stays, and the text stays in
46
+ * the accessibility tree as the button's accessible name.
47
+ *
48
+ * The visible label is therefore HIDDEN in flight — the one thing this
49
+ * trades away, and the reason `loadingLabel` exists.
34
50
  */
35
51
  loading?: boolean;
36
52
  /** Screen-reader-only label announced while `loading` is true. */
@@ -109,7 +125,15 @@
109
125
  aria-busy={loading || undefined}
110
126
  data-state={active ? 'on' : undefined}
111
127
  data-size={size}
112
- class={cn(buttonVariants({ variant, tone, size, icon, active }), className)}
128
+ class={cn(
129
+ buttonVariants({ variant, tone, size, icon, active }),
130
+ // Positioned only while loading — the spinner overlay below needs a
131
+ // containing block, and giving EVERY button one would change paint order
132
+ // against its non-positioned neighbours for a state most of them never
133
+ // enter.
134
+ loading && 'relative',
135
+ className
136
+ )}
113
137
  {...restProps}
114
138
  >
115
139
  <!--
@@ -142,6 +166,13 @@
142
166
  data-button-content
143
167
  class={cn(
144
168
  'inline-flex items-center justify-center [gap:inherit]',
169
+ // THE LABEL KEEPS ITS BOX AND LOSES ITS INK. opacity, not `hidden` or
170
+ // `invisible`: the box has to stay (it is what holds the width) and the
171
+ // text has to stay in the accessibility tree (it is the button's
172
+ // accessible name, and a control that loses its name mid-flight is worse
173
+ // than one that shifts). See the `loading` prop for why the spinner
174
+ // cannot simply sit beside it.
175
+ loading && 'opacity-0',
145
176
  // A link is running text, so its wrapper flows inline and takes NO ink
146
177
  // centring. era-label-ink blockifies the box and trims it to the
147
178
  // typographic band — right for a label in a fixed-height control, and
@@ -151,10 +182,6 @@
151
182
  variant === 'link' ? 'inline' : 'not-has-[svg]:era-label-ink'
152
183
  )}
153
184
  >
154
- {#if loading}
155
- <LoaderCircle class="size-icon shrink-0 animate-spin" aria-hidden="true" />
156
- {#if loadingLabel}<span class="sr-only">{loadingLabel}</span>{/if}
157
- {/if}
158
185
  <!-- The label box exists ONLY when `lead`/`trail` are used. Rendering it
159
186
  unconditionally changed the DOM for every legacy call site: a
160
187
  `display:contents` span has no box, and the geometry audits walk real
@@ -171,4 +198,15 @@
171
198
  {@render children?.()}
172
199
  {/if}
173
200
  </span>
201
+ <!--
202
+ The spinner is OUT OF FLOW, which is the whole point. Absolutely positioned
203
+ children take no part in flex layout, so the button's width, height, gap and
204
+ min-w are the same in flight as at rest.
205
+ -->
206
+ {#if loading}
207
+ <span class="absolute inset-0 flex items-center justify-center">
208
+ <LoaderCircle class="size-icon shrink-0 animate-spin" aria-hidden="true" />
209
+ </span>
210
+ {#if loadingLabel}<span class="sr-only">{loadingLabel}</span>{/if}
211
+ {/if}
174
212
  </Button.Root>
@@ -7,9 +7,25 @@ type $$ComponentProps = Button.RootProps & {
7
7
  active?: boolean;
8
8
  icon?: boolean;
9
9
  /**
10
- * When true, disables the button, sets `aria-busy`, and prepends a
11
- * spinner. The existing label stays rendered so focus, click targets,
12
- * and screen-reader accessible names don't change shape mid-flight.
10
+ * In flight: disables the button, sets `aria-busy`, and puts a spinner
11
+ * over the label.
12
+ *
13
+ * THE GEOMETRY DOES NOT MOVE. That is the requirement, not a detail. A
14
+ * button is loading because it was just CLICKED, so anything `loading`
15
+ * changes about the box moves the page under the pointer that is still
16
+ * on it — and a spinner PREPENDED to the label (what this used to do)
17
+ * costs an icon's width plus a gap, measured on a wrapping header row
18
+ * that reflowed everything beside it. A consumer hit exactly that and
19
+ * stopped using the prop, hand-rolling `disabled` + `aria-busy`
20
+ * instead, which is the report that produced this note.
21
+ *
22
+ * So the spinner is absolutely positioned and takes no part in the
23
+ * flex layout, and the label goes to `opacity-0` rather than being
24
+ * unmounted: the box that holds the width stays, and the text stays in
25
+ * the accessibility tree as the button's accessible name.
26
+ *
27
+ * The visible label is therefore HIDDEN in flight — the one thing this
28
+ * trades away, and the reason `loadingLabel` exists.
13
29
  */
14
30
  loading?: boolean;
15
31
  /** Screen-reader-only label announced while `loading` is true. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@signal9/era-ui",
3
- "version": "30.1.3",
3
+ "version": "30.1.4",
4
4
  "scripts": {
5
5
  "dev": "vite dev --host",
6
6
  "build": "vite build && npm run prepack",