@signal9/era-ui 30.2.0 → 30.2.1

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.2.1](https://github.com/sig-nine/era-ui/compare/v30.2.0...v30.2.1) (2026-08-16)
2
+
3
+ ### Bug Fixes
4
+
5
+ * **select:** the multi-value chips no longer nest a button in the trigger ([c7ff9a2](https://github.com/sig-nine/era-ui/commit/c7ff9a27469b4f5ef2059e4616af38c2bdede8f7))
6
+
1
7
  ## [30.2.0](https://github.com/sig-nine/era-ui/compare/v30.1.4...v30.2.0) (2026-08-16)
2
8
 
3
9
  ### Features
@@ -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. */
@@ -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
  : () =>
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.1",
4
4
  "scripts": {
5
5
  "dev": "vite dev --host",
6
6
  "build": "vite build && npm run prepack",