tosijs-ui 1.7.2 → 1.7.3

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.
@@ -0,0 +1,99 @@
1
+ import { Component as WebComponent, ElementCreator, PartsMap } from 'tosijs';
2
+ import { SvgIcon } from './icons';
3
+ interface PocketBarParts extends PartsMap {
4
+ handle: HTMLButtonElement;
5
+ handleIcon: SvgIcon;
6
+ bar: HTMLDivElement;
7
+ }
8
+ export declare class TosiPocketBar extends WebComponent<PocketBarParts> {
9
+ static preferredTagName: string;
10
+ static initAttributes: {
11
+ icon: string;
12
+ direction: string;
13
+ open: boolean;
14
+ };
15
+ private pinned;
16
+ private get vertical();
17
+ private get resolvedIcon();
18
+ private get floatPosition();
19
+ reposition: () => void;
20
+ private setOpen;
21
+ private handlePointerEnter;
22
+ private handlePointerLeave;
23
+ private handleFocusIn;
24
+ private handleFocusOut;
25
+ toggle: (event?: Event) => void;
26
+ private handleOutsidePointer;
27
+ private handleScrollResize;
28
+ content: () => (HTMLDivElement | HTMLButtonElement)[];
29
+ static shadowStyleSpec: {
30
+ ':host': {
31
+ display: string;
32
+ position: string;
33
+ opacity: string;
34
+ transition: string;
35
+ };
36
+ ':host([open])': {
37
+ opacity: string;
38
+ };
39
+ ':host [part="handle"]': {
40
+ display: string;
41
+ alignItems: string;
42
+ justifyContent: string;
43
+ padding: string;
44
+ border: string;
45
+ margin: string;
46
+ cursor: string;
47
+ color: string;
48
+ background: string;
49
+ backdropFilter: string;
50
+ borderRadius: string;
51
+ transition: string;
52
+ };
53
+ ':host [part="bar"]': {
54
+ position: string;
55
+ display: string;
56
+ gap: string;
57
+ padding: string;
58
+ background: string;
59
+ backdropFilter: string;
60
+ borderRadius: string;
61
+ boxShadow: string;
62
+ opacity: string;
63
+ pointerEvents: string;
64
+ transform: string;
65
+ transition: string;
66
+ };
67
+ ':host([open]) [part="bar"]': {
68
+ opacity: string;
69
+ pointerEvents: string;
70
+ transform: string;
71
+ };
72
+ '::slotted(button), ::slotted(label)': {
73
+ display: string;
74
+ alignItems: string;
75
+ gap: string;
76
+ padding: string;
77
+ margin: string;
78
+ border: string;
79
+ background: string;
80
+ color: string;
81
+ font: string;
82
+ cursor: string;
83
+ borderRadius: string;
84
+ transition: string;
85
+ };
86
+ '::slotted(button:hover), ::slotted(label:hover)': {
87
+ background: string;
88
+ };
89
+ '::slotted(label:has(input[type="checkbox"]:not(:checked)))': {
90
+ opacity: string;
91
+ filter: string;
92
+ };
93
+ };
94
+ connectedCallback(): void;
95
+ disconnectedCallback(): void;
96
+ render(): void;
97
+ }
98
+ export declare const tosiPocketBar: ElementCreator<TosiPocketBar>;
99
+ export {};
@@ -0,0 +1,298 @@
1
+ /*#
2
+ # pocket bar
3
+
4
+ A **pocket toolbar**: a single icon at rest that expands into a bar of your controls
5
+ on hover or tap, then tucks away again. It's translucent until you touch it, so it
6
+ stays out of the way when pinned over content.
7
+
8
+ Whatever you put inside is the toolbar — they're your light-DOM children, so *any*
9
+ control works: buttons, a checkbox, a segmented control. The component only supplies
10
+ the handle, the reveal, and the positioning.
11
+
12
+ Hover (or focus, or tap) peeks it open; **click the handle to keep it open**, and
13
+ click the handle again — or anywhere outside — to dismiss it.
14
+
15
+ ```html
16
+ <div class="pocket-demo">
17
+ <tosi-pocket-bar direction="e">
18
+ <button title="Cut"><tosi-icon icon="scissors"></tosi-icon></button>
19
+ <button title="Copy"><tosi-icon icon="copy"></tosi-icon></button>
20
+ <button title="Paste"><tosi-icon icon="clipboard"></tosi-icon></button>
21
+ </tosi-pocket-bar>
22
+
23
+ <tosi-pocket-bar direction="s">
24
+ <button title="Bold"><tosi-icon icon="bold"></tosi-icon></button>
25
+ <button title="Italic"><tosi-icon icon="italic"></tosi-icon></button>
26
+ <button title="Underline"><tosi-icon icon="underline"></tosi-icon></button>
27
+ </tosi-pocket-bar>
28
+
29
+ <tosi-pocket-bar direction="w">
30
+ <button title="Zoom in"><tosi-icon icon="zoomIn"></tosi-icon></button>
31
+ <button title="Zoom out"><tosi-icon icon="zoomOut"></tosi-icon></button>
32
+ </tosi-pocket-bar>
33
+
34
+ <tosi-pocket-bar direction="n" icon="settings">
35
+ <label title="Snap to grid"><input type="checkbox" checked /><tosi-icon icon="grid"></tosi-icon></label>
36
+ <button title="Undo"><tosi-icon icon="cornerUpLeft"></tosi-icon></button>
37
+ <button title="Redo"><tosi-icon icon="cornerUpRight"></tosi-icon></button>
38
+ </tosi-pocket-bar>
39
+ </div>
40
+ ```
41
+ ```css
42
+ .preview .pocket-demo {
43
+ display: flex;
44
+ align-items: center;
45
+ justify-content: space-around;
46
+ height: 260px;
47
+ padding: 60px 20px;
48
+ }
49
+ .preview .pocket-demo tosi-pocket-bar button,
50
+ .preview .pocket-demo tosi-pocket-bar label {
51
+ display: inline-flex;
52
+ align-items: center;
53
+ gap: 4px;
54
+ padding: 5px;
55
+ border: 0;
56
+ border-radius: 6px;
57
+ background: transparent;
58
+ color: inherit;
59
+ cursor: pointer;
60
+ }
61
+ .preview .pocket-demo tosi-pocket-bar button:hover,
62
+ .preview .pocket-demo tosi-pocket-bar label:hover {
63
+ background: #8882;
64
+ }
65
+ ```
66
+
67
+ Each handle above hints its axis — `⋯` for the horizontal bars (`e`, `w`), `⋮` for the
68
+ vertical ones (`n`, `s`) — and the fourth uses an explicit `settings` icon. Hover any of
69
+ them to see the bar grow in its `direction`.
70
+
71
+ ## Attributes
72
+
73
+ - `icon` — the handle icon (any icon name or [composition](icon-composition)). By
74
+ default it hints the growth axis: `moreHorizontal` (⋯) for a horizontal bar,
75
+ `moreVertical` (⋮) for a vertical one.
76
+ - `direction` — where the bar grows, using the `FloatPosition` vocabulary
77
+ (`n | e | s | w | ne | nw | se | sw | en | wn | es | ws | side | auto`). `auto`
78
+ (the default) grows a **horizontal** bar toward the nearer edge of the screen.
79
+ `e` / `w` (and `en` / `es` / `wn` / `ws`) grow a horizontal bar; `n` / `s` (and
80
+ `ne` / `nw` / `se` / `sw`) grow a **vertical** one. The bar is placed
81
+ edge-contiguous with the handle via [positionFloat](popFloat).
82
+ - `open` — reflects the open state. Hover / focus / tap peek it open; clicking the
83
+ handle keeps it open until you click the handle again or click outside.
84
+
85
+ The handle is slightly translucent at rest (`--tosi-pocket-opacity`, default `0.75`)
86
+ and opaque while open; it keeps a blurred glass chip (`--tosi-pocket-bg`, a translucent
87
+ tint of `--tosi-bg`, so it follows the theme) so it stays legible over busy content.
88
+
89
+ ### Styling hooks
90
+
91
+ - `--tosi-pocket-opacity` — resting opacity of the whole widget (default `0.75`).
92
+ - `--tosi-pocket-bg` — the glass background (a translucent tint of `--tosi-bg`).
93
+ - `--tosi-pocket-handle-color` — colour of just the collapsed handle icon, independent
94
+ of the bar's controls (e.g. to flag status on the handle alone). Default `inherit`.
95
+
96
+ Slotted `<button>`s and `<label>`s get flat icon-button styling by default. A
97
+ checkbox-as-icon — a `<label>` wrapping an `<input type=checkbox>` and an icon — greys
98
+ and desaturates until checked, so a toggle needs no extra CSS.
99
+ */
100
+ /*{ "parent": "Components" }*/
101
+ import { Component as WebComponent, elements, vars, varDefault, } from 'tosijs';
102
+ import { svgIcon } from './icons';
103
+ import { positionFloat } from './pop-float';
104
+ const { button, div, slot } = elements;
105
+ export class TosiPocketBar extends WebComponent {
106
+ static preferredTagName = 'tosi-pocket-bar';
107
+ static initAttributes = {
108
+ icon: '',
109
+ direction: 'auto',
110
+ open: false,
111
+ };
112
+ // Whether the bar is *pinned* open by a click (vs. a transient hover/focus peek).
113
+ pinned = false;
114
+ // n/s grow a vertical bar; everything else (auto, e/w, side) is horizontal.
115
+ get vertical() {
116
+ return /^[ns]/.test(this.direction);
117
+ }
118
+ // Default handle hints the growth axis: ⋮ for a vertical bar, ⋯ for a horizontal
119
+ // one. An explicit `icon` always wins.
120
+ get resolvedIcon() {
121
+ return this.icon || (this.vertical ? 'moreVertical' : 'moreHorizontal');
122
+ }
123
+ // `auto` → positionFloat's `side` mode: a horizontal bar toward the nearer screen
124
+ // edge. Everything else is a FloatPosition passed straight through.
125
+ get floatPosition() {
126
+ return (this.direction === 'auto' ? 'side' : this.direction);
127
+ }
128
+ reposition = () => {
129
+ if (!this.hydrated)
130
+ return;
131
+ positionFloat(this.parts.bar, this.parts.handle, this.floatPosition, 'remain', 'remain');
132
+ };
133
+ // Open is driven authoritatively in JS (not CSS :hover), so an explicit close —
134
+ // clicking the handle again, or clicking outside — always wins over a lingering
135
+ // hover. Otherwise a click-to-open bar could never be dismissed while the pointer
136
+ // stayed over it.
137
+ setOpen(open) {
138
+ if (this.open !== open)
139
+ this.open = open;
140
+ if (open)
141
+ this.reposition();
142
+ }
143
+ handlePointerEnter = () => this.setOpen(true);
144
+ handlePointerLeave = () => {
145
+ if (!this.pinned)
146
+ this.setOpen(false);
147
+ };
148
+ handleFocusIn = () => this.setOpen(true);
149
+ handleFocusOut = () => {
150
+ // Close once focus has left the whole component (unless pinned) — next frame,
151
+ // after focus has landed on its new target.
152
+ requestAnimationFrame(() => {
153
+ if (!this.pinned && !this.matches(':focus-within'))
154
+ this.setOpen(false);
155
+ });
156
+ };
157
+ toggle = (event) => {
158
+ event?.preventDefault();
159
+ this.pinned = !this.pinned;
160
+ this.setOpen(this.pinned);
161
+ };
162
+ // A pointer-down anywhere outside a pinned bar dismisses it (composedPath so it
163
+ // works across the shadow boundary and the position:fixed bar).
164
+ handleOutsidePointer = (event) => {
165
+ if (!this.pinned)
166
+ return;
167
+ const path = event.composedPath();
168
+ if (!path.includes(this) && !path.includes(this.parts.bar)) {
169
+ this.pinned = false;
170
+ this.setOpen(false);
171
+ }
172
+ };
173
+ handleScrollResize = () => {
174
+ if (this.open)
175
+ this.reposition();
176
+ };
177
+ content = () => [
178
+ button({
179
+ part: 'handle',
180
+ class: 'no-drag',
181
+ title: 'toolbar',
182
+ 'aria-label': 'toolbar',
183
+ 'aria-expanded': 'false',
184
+ onClick: this.toggle,
185
+ }, svgIcon({ part: 'handleIcon', icon: this.resolvedIcon })),
186
+ div({
187
+ part: 'bar',
188
+ role: 'toolbar',
189
+ // Set at construction (render() keeps it in sync if `direction` changes).
190
+ style: { flexDirection: this.vertical ? 'column' : 'row' },
191
+ }, slot()),
192
+ ];
193
+ static shadowStyleSpec = {
194
+ ':host': {
195
+ display: 'inline-flex',
196
+ position: 'relative',
197
+ // Slightly translucent at rest so it stays out of the way, fully opaque when
198
+ // open. The handle keeps a blurred glass chip even at rest (below), so it
199
+ // stays legible over busy content instead of a bare, hard-to-see icon.
200
+ opacity: varDefault.tosiPocketOpacity('0.75'),
201
+ transition: 'opacity 0.15s ease-out',
202
+ },
203
+ ':host([open])': {
204
+ opacity: '1',
205
+ },
206
+ ':host [part="handle"]': {
207
+ display: 'inline-flex',
208
+ alignItems: 'center',
209
+ justifyContent: 'center',
210
+ padding: vars.spacing50,
211
+ border: '0',
212
+ margin: '0',
213
+ cursor: 'pointer',
214
+ // The handle's colour is independently settable (the bar's controls stay
215
+ // neutral), so a consumer can e.g. flag status on just the collapsed icon.
216
+ color: varDefault.tosiPocketHandleColor('inherit'),
217
+ // A blurred "glass" chip tinted from --tosi-bg (theme-aware, light or dark).
218
+ // The backdrop blur keeps the icon readable over any background.
219
+ background: varDefault.tosiPocketBg('color-mix(in srgb, var(--tosi-bg, #fff) 85%, transparent)'),
220
+ backdropFilter: 'blur(12px)',
221
+ borderRadius: vars.spacing,
222
+ transition: 'background 0.15s ease-out',
223
+ },
224
+ ':host [part="bar"]': {
225
+ position: 'fixed',
226
+ display: 'flex',
227
+ gap: vars.spacing25,
228
+ padding: vars.spacing25,
229
+ background: varDefault.tosiPocketBg('color-mix(in srgb, var(--tosi-bg, #fff) 82%, transparent)'),
230
+ backdropFilter: 'blur(12px)',
231
+ borderRadius: vars.spacing,
232
+ boxShadow: `0 ${vars.spacing25} ${vars.spacing} #0003`,
233
+ // Hidden and inert at rest; revealed when open.
234
+ opacity: '0',
235
+ pointerEvents: 'none',
236
+ transform: 'scale(0.9)',
237
+ transition: 'opacity 0.15s ease-out, transform 0.15s ease-out',
238
+ },
239
+ ':host([open]) [part="bar"]': {
240
+ opacity: '1',
241
+ pointerEvents: 'auto',
242
+ transform: 'scale(1)',
243
+ },
244
+ // Sensible defaults for slotted controls so a bar of icon-buttons needs no
245
+ // extra CSS: flat icon-buttons with a subtle hover tint. A checkbox-as-icon
246
+ // (label wrapping an <input type=checkbox> + an icon) greys and desaturates
247
+ // until it's checked — the easy way to build a toggle. Consumers override any
248
+ // of this from the light DOM (higher specificity) or hide the native checkbox.
249
+ '::slotted(button), ::slotted(label)': {
250
+ display: 'inline-flex',
251
+ alignItems: 'center',
252
+ gap: vars.spacing25,
253
+ padding: vars.spacing50,
254
+ margin: '0',
255
+ border: '0',
256
+ background: 'transparent',
257
+ color: 'inherit',
258
+ font: 'inherit',
259
+ cursor: 'pointer',
260
+ borderRadius: vars.spacing25,
261
+ transition: 'background 0.15s ease-out, opacity 0.15s, filter 0.15s',
262
+ },
263
+ '::slotted(button:hover), ::slotted(label:hover)': {
264
+ background: 'color-mix(in srgb, currentColor 15%, transparent)',
265
+ },
266
+ '::slotted(label:has(input[type="checkbox"]:not(:checked)))': {
267
+ opacity: '0.45',
268
+ filter: 'grayscale(1)',
269
+ },
270
+ };
271
+ connectedCallback() {
272
+ super.connectedCallback();
273
+ this.addEventListener('pointerenter', this.handlePointerEnter);
274
+ this.addEventListener('pointerleave', this.handlePointerLeave);
275
+ this.addEventListener('focusin', this.handleFocusIn);
276
+ this.addEventListener('focusout', this.handleFocusOut);
277
+ document.addEventListener('pointerdown', this.handleOutsidePointer, true);
278
+ window.addEventListener('scroll', this.handleScrollResize, {
279
+ passive: true,
280
+ capture: true,
281
+ });
282
+ window.addEventListener('resize', this.handleScrollResize, { passive: true });
283
+ }
284
+ disconnectedCallback() {
285
+ super.disconnectedCallback();
286
+ document.removeEventListener('pointerdown', this.handleOutsidePointer, true);
287
+ window.removeEventListener('scroll', this.handleScrollResize, true);
288
+ window.removeEventListener('resize', this.handleScrollResize);
289
+ }
290
+ render() {
291
+ super.render();
292
+ this.parts.handleIcon.icon = this.resolvedIcon;
293
+ this.parts.bar.style.flexDirection = this.vertical ? 'column' : 'row';
294
+ this.parts.handle.setAttribute('aria-expanded', String(!!this.open));
295
+ this.reposition();
296
+ }
297
+ }
298
+ export const tosiPocketBar = TosiPocketBar.elementCreator();
@@ -11,4 +11,4 @@ export interface PopFloatOptions {
11
11
  draggable?: boolean;
12
12
  }
13
13
  export declare const popFloat: (options: PopFloatOptions) => TosiFloat;
14
- export declare const positionFloat: (element: TosiFloat, target: HTMLElement, position?: FloatPosition, remainOnScroll?: "hide" | "remove" | "remain", remainOnResize?: "hide" | "remove" | "remain", draggable?: boolean) => void;
14
+ export declare const positionFloat: (element: HTMLElement, target: HTMLElement, position?: FloatPosition, remainOnScroll?: "hide" | "remove" | "remain", remainOnResize?: "hide" | "remove" | "remain", draggable?: boolean) => void;
package/dist/pop-float.js CHANGED
@@ -241,7 +241,7 @@ preview.append(button(
241
241
  ```
242
242
 
243
243
  */
244
- import { tosiFloat } from './float';
244
+ import { tosiFloat, TosiFloat } from './float';
245
245
  import { bringToFront } from './track-drag';
246
246
  export const popFloat = (options) => {
247
247
  const { content, target, position, remainOnScroll, remainOnResize, draggable, } = options;
@@ -255,19 +255,25 @@ export const popFloat = (options) => {
255
255
  document.body.append(float);
256
256
  return float;
257
257
  };
258
- export const positionFloat = (element, target, position, remainOnScroll, remainOnResize, draggable = false) => {
258
+ export const positionFloat = (
259
+ // Any element can be positioned relative to a target — only the remain-on-* /
260
+ // drag behaviors below are TosiFloat-specific (guarded), the geometry is not.
261
+ // (A `<tosi-pocket-bar>` positions its plain bar element this way.)
262
+ element, target, position, remainOnScroll, remainOnResize, draggable = false) => {
259
263
  {
260
264
  const { position } = getComputedStyle(element);
261
265
  if (position !== 'fixed') {
262
266
  element.style.position = 'fixed';
263
267
  }
264
- if (remainOnResize)
265
- element.remainOnResize = remainOnResize;
266
- if (remainOnScroll)
267
- element.remainOnScroll = remainOnScroll;
268
+ if (element instanceof TosiFloat) {
269
+ if (remainOnResize)
270
+ element.remainOnResize = remainOnResize;
271
+ if (remainOnScroll)
272
+ element.remainOnScroll = remainOnScroll;
273
+ element.drag = draggable;
274
+ }
268
275
  bringToFront(element);
269
276
  }
270
- element.drag = draggable;
271
277
  const { left, top, width, height } = target.getBoundingClientRect();
272
278
  const cx = left + width * 0.5;
273
279
  const cy = top + height * 0.5;
@@ -87,7 +87,6 @@ export declare class TosiSegmented extends WebComponent {
87
87
  };
88
88
  };
89
89
  private syncCurrent;
90
- private valueChanged;
91
90
  handleChange: () => void;
92
91
  handleKey: (event: KeyboardEvent) => void;
93
92
  connectedCallback(): void;
package/dist/segmented.js CHANGED
@@ -262,17 +262,20 @@ export class TosiSegmented extends WebComponent {
262
262
  opacity: varDefault.segmentedPlaceholderOpacity(0.75),
263
263
  },
264
264
  };
265
- // Reflects each radio/checkbox's checked state onto its containing label as a
266
- // `.current` class so the selection highlight updates reliably (see note on the
267
- // `.current` style rule).
265
+ // Reflects the selection onto each label as a `.current` class so the highlight
266
+ // updates reliably (see note on the `.current` style rule). Derived from
267
+ // `this.value` (the authoritative selection, matching render()'s `checked` logic),
268
+ // NOT from re-reading each `input.checked`: on Firefox/WebKit that live read can
269
+ // lag the native radio update at change time, leaving `.current` on the previous
270
+ // segment while `input.checked` (and `value`) are already correct.
268
271
  syncCurrent() {
269
272
  const { options } = this.parts;
273
+ const { values, isOtherValue } = this;
270
274
  options.querySelectorAll('label').forEach((label) => {
271
- const input = label.querySelector('input');
272
- label.classList.toggle('current', Boolean(input && input.checked));
275
+ const val = label.querySelector('input')?.value ?? '';
276
+ label.classList.toggle('current', values.includes(val) || (val === '' && isOtherValue));
273
277
  });
274
278
  }
275
- valueChanged = false;
276
279
  handleChange = () => {
277
280
  const { options, custom } = this.parts;
278
281
  if (this.multiple) {
@@ -298,7 +301,6 @@ export class TosiSegmented extends WebComponent {
298
301
  }
299
302
  }
300
303
  this.syncCurrent();
301
- this.valueChanged = true;
302
304
  };
303
305
  handleKey = (event) => {
304
306
  let blockEvent = false;
@@ -378,29 +380,45 @@ export class TosiSegmented extends WebComponent {
378
380
  }
379
381
  render() {
380
382
  super.render();
381
- if (this.valueChanged) {
382
- this.valueChanged = false;
383
- return;
384
- }
385
383
  const { options, custom } = this.parts;
386
- options.textContent = '';
387
384
  const type = this.multiple ? 'checkbox' : 'radio';
388
385
  const { values, isOtherValue } = this;
389
- options.append(...this._choicesWithOther.map((choice) => {
390
- return label({ tabindex: 0 }, input({
386
+ const choices = this._choicesWithOther;
387
+ const isChecked = (value) => values.includes(value) || (value === '' && isOtherValue);
388
+ // Idempotent: rebuild the <label>s ONLY when the choice set changed; otherwise
389
+ // reconcile `checked` in place. Re-creating them every render (previously avoided
390
+ // by a `valueChanged` timing flag) dropped focus from the segment you just
391
+ // clicked and is provably redundant when the labels already match the choices —
392
+ // so render is now safe to run any number of times, any time.
393
+ const labels = [...options.querySelectorAll('label')];
394
+ const structureMatches = labels.length === choices.length &&
395
+ labels.every((l, i) => l.querySelector('input')?.value === choices[i].value);
396
+ if (structureMatches) {
397
+ labels.forEach((label) => {
398
+ const input = label.querySelector('input');
399
+ if (input && input.checked !== isChecked(input.value)) {
400
+ input.checked = isChecked(input.value);
401
+ }
402
+ });
403
+ }
404
+ else {
405
+ options.textContent = '';
406
+ options.append(...choices.map((choice) => label({ tabindex: 0 }, input({
391
407
  type,
392
408
  name: this.name,
393
409
  value: choice.value,
394
- checked: values.includes(choice.value) ||
395
- (choice.value === '' && isOtherValue),
410
+ checked: isChecked(choice.value),
396
411
  tabIndex: -1,
397
- }), choice.icon || { class: 'no-icon' }, this.localized ? tosiLocalized(choice.caption) : span(choice.caption));
398
- }));
412
+ }), choice.icon || { class: 'no-icon' }, this.localized ? tosiLocalized(choice.caption) : span(choice.caption))));
413
+ }
399
414
  if (this.other && !this.multiple) {
400
415
  custom.hidden = !isOtherValue;
401
- custom.value = isOtherValue ? this.value : '';
416
+ const customValue = isOtherValue ? this.value : '';
417
+ if (custom.value !== customValue)
418
+ custom.value = customValue;
402
419
  custom.placeholder = this.placeholder;
403
- options.append(custom);
420
+ if (custom.parentElement !== options)
421
+ options.append(custom);
404
422
  }
405
423
  this.syncCurrent();
406
424
  }