svelte-multiselect 11.1.1 → 11.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.
Files changed (42) hide show
  1. package/dist/CircleSpinner.svelte +2 -1
  2. package/dist/CmdPalette.svelte +16 -9
  3. package/dist/CmdPalette.svelte.d.ts +4 -5
  4. package/dist/CodeExample.svelte +80 -0
  5. package/dist/CodeExample.svelte.d.ts +22 -0
  6. package/dist/CopyButton.svelte +47 -0
  7. package/dist/CopyButton.svelte.d.ts +25 -0
  8. package/dist/FileDetails.svelte +52 -0
  9. package/dist/FileDetails.svelte.d.ts +20 -0
  10. package/dist/GitHubCorner.svelte +82 -0
  11. package/dist/GitHubCorner.svelte.d.ts +13 -0
  12. package/dist/Icon.svelte +23 -0
  13. package/dist/Icon.svelte.d.ts +8 -0
  14. package/dist/MultiSelect.svelte +129 -76
  15. package/dist/MultiSelect.svelte.d.ts +2 -3
  16. package/dist/PrevNext.svelte +100 -0
  17. package/dist/PrevNext.svelte.d.ts +48 -0
  18. package/dist/RadioButtons.svelte +67 -0
  19. package/dist/RadioButtons.svelte.d.ts +44 -0
  20. package/dist/Toggle.svelte +78 -0
  21. package/dist/Toggle.svelte.d.ts +16 -0
  22. package/dist/icons.d.ts +47 -0
  23. package/dist/icons.js +46 -0
  24. package/dist/index.d.ts +10 -3
  25. package/dist/index.js +10 -3
  26. package/dist/types.d.ts +141 -0
  27. package/dist/utils.d.ts +6 -22
  28. package/dist/utils.js +17 -25
  29. package/package.json +17 -36
  30. package/readme.md +287 -121
  31. package/dist/icons/ChevronExpand.svelte +0 -9
  32. package/dist/icons/ChevronExpand.svelte.d.ts +0 -4
  33. package/dist/icons/Cross.svelte +0 -10
  34. package/dist/icons/Cross.svelte.d.ts +0 -4
  35. package/dist/icons/Disabled.svelte +0 -10
  36. package/dist/icons/Disabled.svelte.d.ts +0 -4
  37. package/dist/icons/Octocat.svelte +0 -9
  38. package/dist/icons/Octocat.svelte.d.ts +0 -4
  39. package/dist/icons/index.d.ts +0 -4
  40. package/dist/icons/index.js +0 -4
  41. package/dist/props.d.ts +0 -143
  42. package/dist/props.js +0 -1
@@ -1,14 +1,12 @@
1
- <script lang="ts">import { tick } from 'svelte';
1
+ <script lang="ts">import { CircleSpinner, Icon, Wiggle } from './';
2
+ import { tick } from 'svelte';
2
3
  import { flip } from 'svelte/animate';
3
- import CircleSpinner from './CircleSpinner.svelte';
4
- import Wiggle from './Wiggle.svelte';
5
- import { CrossIcon, DisabledIcon, ExpandIcon } from './icons';
6
4
  import { get_label, get_style, highlight_matching_nodes } from './utils';
7
5
  let { activeIndex = $bindable(null), activeOption = $bindable(null), createOptionMsg = `Create this option...`, allowUserOptions = false, allowEmpty = false, autocomplete = `off`, autoScroll = true, breakpoint = 800, defaultDisabledTitle = `This option is disabled`, disabled = false, disabledInputTitle = `This input is disabled`, duplicateOptionMsg = `This option is already selected`, duplicates = false, key = (opt) => `${get_label(opt)}`.toLowerCase(), filterFunc = (opt, searchText) => {
8
6
  if (!searchText)
9
7
  return true;
10
8
  return `${get_label(opt)}`.toLowerCase().includes(searchText.toLowerCase());
11
- }, closeDropdownOnSelect = `desktop`, form_input = $bindable(null), highlightMatches = true, id = null, input = $bindable(null), inputClass = ``, inputStyle = null, inputmode = null, invalid = $bindable(false), liActiveOptionClass = ``, liActiveUserMsgClass = ``, liOptionClass = ``, liOptionStyle = null, liSelectedClass = ``, liSelectedStyle = null, liUserMsgClass = ``, loading = false, matchingOptions = $bindable([]), maxOptions = undefined, maxSelect = null, maxSelectMsg = (current, max) => (max > 1 ? `${current}/${max}` : ``), maxSelectMsgClass = ``, name = null, noMatchingOptionsMsg = `No matching options`, open = $bindable(false), options = $bindable(), outerDiv = $bindable(null), outerDivClass = ``, parseLabelsAsHtml = false, pattern = null, placeholder = null, removeAllTitle = `Remove all`, removeBtnTitle = `Remove`, minSelect = null, required = false, resetFilterOnAdd = true, searchText = $bindable(``), selected = $bindable(options
9
+ }, closeDropdownOnSelect = `if-mobile`, form_input = $bindable(null), highlightMatches = true, id = null, input = $bindable(null), inputClass = ``, inputStyle = null, inputmode = null, invalid = $bindable(false), liActiveOptionClass = ``, liActiveUserMsgClass = ``, liOptionClass = ``, liOptionStyle = null, liSelectedClass = ``, liSelectedStyle = null, liUserMsgClass = ``, loading = false, matchingOptions = $bindable([]), maxOptions = undefined, maxSelect = null, maxSelectMsg = (current, max) => (max > 1 ? `${current}/${max}` : ``), maxSelectMsgClass = ``, name = null, noMatchingOptionsMsg = `No matching options`, open = $bindable(false), options = $bindable(), outerDiv = $bindable(null), outerDivClass = ``, parseLabelsAsHtml = false, pattern = null, placeholder = null, removeAllTitle = `Remove all`, removeBtnTitle = `Remove`, minSelect = null, required = false, resetFilterOnAdd = true, searchText = $bindable(``), selected = $bindable(options
12
10
  ?.filter((opt) => opt instanceof Object && opt?.preselected)
13
11
  .slice(0, maxSelect ?? undefined) ?? []), sortSelected = false, selectedOptionsDraggable = !sortSelected, style = null, ulOptionsClass = ``, ulSelectedClass = ``, ulSelectedStyle = null, ulOptionsStyle = null, value = $bindable(null), expandIcon, selectedItem, children, removeIcon, afterInput, spinner, disabledIcon, option, userMsg, onblur, onclick, onfocus, onkeydown, onkeyup, onmousedown, onmouseenter, onmouseleave, ontouchcancel, ontouchend, ontouchmove, ontouchstart, onadd, oncreate, onremove, onremoveAll, onchange, onopen, onclose, portal: portal_params = {}, ...rest } = $props();
14
12
  $effect.pre(() => {
@@ -105,8 +103,9 @@ function add(option_to_add, event) {
105
103
  else {
106
104
  option_to_add = searchText; // else create custom option as string
107
105
  }
108
- oncreate?.({ option: option_to_add });
109
106
  }
107
+ // Fire oncreate event for all user-created options, regardless of type
108
+ oncreate?.({ option: option_to_add });
110
109
  if (allowUserOptions === `append`)
111
110
  options = [...options, option_to_add];
112
111
  }
@@ -135,9 +134,12 @@ function add(option_to_add, event) {
135
134
  }
136
135
  const reached_max_select = selected.length === maxSelect;
137
136
  const dropdown_should_close = closeDropdownOnSelect === true ||
138
- (closeDropdownOnSelect === `desktop` && window_width && window_width < breakpoint);
137
+ closeDropdownOnSelect === `retain-focus` ||
138
+ (closeDropdownOnSelect === `if-mobile` && window_width &&
139
+ window_width < breakpoint);
140
+ const should_retain_focus = closeDropdownOnSelect === `retain-focus`;
139
141
  if (reached_max_select || dropdown_should_close) {
140
- close_dropdown(event);
142
+ close_dropdown(event, should_retain_focus);
141
143
  }
142
144
  else if (!dropdown_should_close) {
143
145
  input?.focus();
@@ -182,9 +184,10 @@ function open_dropdown(event) {
182
184
  }
183
185
  onopen?.({ event });
184
186
  }
185
- function close_dropdown(event) {
187
+ function close_dropdown(event, retain_focus = false) {
186
188
  open = false;
187
- input?.blur();
189
+ if (!retain_focus)
190
+ input?.blur();
188
191
  activeIndex = null;
189
192
  onclose?.({ event });
190
193
  }
@@ -195,8 +198,7 @@ async function handle_keydown(event) {
195
198
  event.stopPropagation();
196
199
  close_dropdown(event);
197
200
  searchText = ``;
198
- }
199
- // on enter key: toggle active option and reset search text
201
+ } // on enter key: toggle active option and reset search text
200
202
  else if (event.key === `Enter`) {
201
203
  event.stopPropagation();
202
204
  event.preventDefault(); // prevent enter key from triggering form submission
@@ -216,8 +218,7 @@ async function handle_keydown(event) {
216
218
  // in which case enter means open it
217
219
  open_dropdown(event);
218
220
  }
219
- }
220
- // on up/down arrow keys: update active option
221
+ } // on up/down arrow keys: update active option
221
222
  else if ([`ArrowDown`, `ArrowUp`].includes(event.key)) {
222
223
  event.stopPropagation();
223
224
  // if no option is active yet, but there are matching options, make first one active
@@ -252,14 +253,12 @@ async function handle_keydown(event) {
252
253
  if (li)
253
254
  li.scrollIntoViewIfNeeded?.();
254
255
  }
255
- }
256
- // on backspace key: remove last selected option
256
+ } // on backspace key: remove last selected option
257
257
  else if (event.key === `Backspace` && selected.length > 0 && !searchText) {
258
258
  event.stopPropagation();
259
259
  // Don't prevent default, allow normal backspace behavior if not removing
260
260
  remove(selected.at(-1), event);
261
- }
262
- // make first matching option active on any keypress (if none of the above special cases match)
261
+ } // make first matching option active on any keypress (if none of the above special cases match)
263
262
  else if (matchingOptions.length > 0 && activeIndex === null) {
264
263
  // Don't stop propagation or prevent default here, allow normal character input
265
264
  activeIndex = 0;
@@ -267,10 +266,11 @@ async function handle_keydown(event) {
267
266
  }
268
267
  function remove_all(event) {
269
268
  event.stopPropagation();
269
+ selected = []; // Set selected first
270
+ searchText = ``;
271
+ // Now trigger change events
270
272
  onremoveAll?.({ options: selected });
271
273
  onchange?.({ options: selected, type: `removeAll` });
272
- selected = [];
273
- searchText = ``;
274
274
  }
275
275
  let is_selected = $derived((label) => selected.map(get_label).includes(label));
276
276
  const if_enter_or_space = (handler) => (event) => {
@@ -328,10 +328,38 @@ const handle_input_keydown = (event) => {
328
328
  onkeydown?.(event);
329
329
  };
330
330
  const handle_input_focus = (event) => {
331
- open_dropdown(event); // Internal logic
332
- // Call original forwarded handler
331
+ open_dropdown(event);
333
332
  onfocus?.(event);
334
333
  };
334
+ // Override input's focus method to ensure dropdown opens on programmatic focus
335
+ // https://github.com/janosh/svelte-multiselect/issues/289
336
+ $effect(() => {
337
+ if (!input)
338
+ return;
339
+ const orig_focus = input.focus.bind(input);
340
+ input.focus = (options) => {
341
+ orig_focus(options);
342
+ if (!disabled && !open) {
343
+ open_dropdown(new FocusEvent(`focus`, { bubbles: true }));
344
+ }
345
+ };
346
+ return () => {
347
+ if (input)
348
+ input.focus = orig_focus;
349
+ };
350
+ });
351
+ const handle_input_blur = (event) => {
352
+ // For portalled dropdowns, don't close on blur since clicks on portalled elements
353
+ // will cause blur but we want to allow the click to register first
354
+ if (portal_params?.active) {
355
+ onblur?.(event); // Let the click handler manage closing for portalled dropdowns
356
+ return;
357
+ }
358
+ // For non-portalled dropdowns, close when focus moves outside the component
359
+ if (!outerDiv?.contains(event.relatedTarget))
360
+ close_dropdown(event);
361
+ onblur?.(event); // Call original handler (if any passed as component prop)
362
+ };
335
363
  // reset form validation when required prop changes
336
364
  // https://github.com/janosh/svelte-multiselect/issues/285
337
365
  $effect.pre(() => {
@@ -342,7 +370,8 @@ function portal(node, params) {
342
370
  let { target_node, active } = params;
343
371
  if (!active)
344
372
  return;
345
- let render_in_place = typeof window === `undefined` || !document.body.contains(node);
373
+ let render_in_place = typeof window === `undefined` ||
374
+ !document.body.contains(node);
346
375
  if (!render_in_place) {
347
376
  document.body.appendChild(node);
348
377
  node.style.position = `fixed`;
@@ -368,7 +397,8 @@ function portal(node, params) {
368
397
  return {
369
398
  update(params) {
370
399
  target_node = params.target_node;
371
- render_in_place = typeof window === `undefined` || !document.body.contains(node);
400
+ render_in_place = typeof window === `undefined` ||
401
+ !document.body.contains(node);
372
402
  if (open && !render_in_place && target_node)
373
403
  tick().then(update_position);
374
404
  else if (!open || !target_node)
@@ -432,7 +462,11 @@ function portal(node, params) {
432
462
  {#if expandIcon}
433
463
  {@render expandIcon({ open })}
434
464
  {:else}
435
- <ExpandIcon width="15px" style="min-width: 1em; padding: 0 1pt; cursor: pointer;" />
465
+ <Icon
466
+ icon="ChevronExpand"
467
+ width="15px"
468
+ style="min-width: 1em; padding: 0 1pt; cursor: pointer"
469
+ />
436
470
  {/if}
437
471
  <ul
438
472
  class="selected {ulSelectedClass}"
@@ -441,7 +475,9 @@ function portal(node, params) {
441
475
  >
442
476
  {#each selected as option, idx (duplicates ? [key(option), idx] : key(option))}
443
477
  {@const selectedOptionStyle =
444
- [get_style(option, `selected`), liSelectedStyle].filter(Boolean).join(` `) ||
478
+ [get_style(option, `selected`), liSelectedStyle].filter(Boolean).join(
479
+ ` `,
480
+ ) ||
445
481
  null}
446
482
  <li
447
483
  class={liSelectedClass}
@@ -460,14 +496,14 @@ function portal(node, params) {
460
496
  >
461
497
  {#if selectedItem}
462
498
  {@render selectedItem({
463
- option,
464
- idx,
465
- })}
499
+ option,
500
+ idx,
501
+ })}
466
502
  {:else if children}
467
503
  {@render children({
468
- option,
469
- idx,
470
- })}
504
+ option,
505
+ idx,
506
+ })}
471
507
  {:else if parseLabelsAsHtml}
472
508
  {@html get_label(option)}
473
509
  {:else}
@@ -484,7 +520,7 @@ function portal(node, params) {
484
520
  {#if removeIcon}
485
521
  {@render removeIcon()}
486
522
  {:else}
487
- <CrossIcon width="15px" />
523
+ <Icon icon="Cross" width="15px" />
488
524
  {/if}
489
525
  </button>
490
526
  {/if}
@@ -507,7 +543,7 @@ function portal(node, params) {
507
543
  onkeydown={handle_input_keydown}
508
544
  onfocus={handle_input_focus}
509
545
  oninput={highlight_matching_options}
510
- {onblur}
546
+ onblur={handle_input_blur}
511
547
  {onclick}
512
548
  {onkeyup}
513
549
  {onmousedown}
@@ -519,16 +555,15 @@ function portal(node, params) {
519
555
  {ontouchstart}
520
556
  {...rest}
521
557
  />
522
- <!-- the above on:* lines forward potentially useful DOM events -->
523
558
  {@render afterInput?.({
524
- selected,
525
- disabled,
526
- invalid,
527
- id,
528
- placeholder,
529
- open,
530
- required,
531
- })}
559
+ selected,
560
+ disabled,
561
+ invalid,
562
+ id,
563
+ placeholder,
564
+ open,
565
+ required,
566
+ })}
532
567
  </ul>
533
568
  {#if loading}
534
569
  {#if spinner}
@@ -541,7 +576,12 @@ function portal(node, params) {
541
576
  {#if disabledIcon}
542
577
  {@render disabledIcon()}
543
578
  {:else}
544
- <DisabledIcon width="14pt" style="margin: 0 2pt;" data-name="disabled-icon" />
579
+ <Icon
580
+ icon="Disabled"
581
+ width="14pt"
582
+ style="margin: 0 2pt"
583
+ data-name="disabled-icon"
584
+ />
545
585
  {/if}
546
586
  {:else if selected.length > 0}
547
587
  {#if maxSelect && (maxSelect > 1 || maxSelectMsg)}
@@ -562,7 +602,7 @@ function portal(node, params) {
562
602
  {#if removeIcon}
563
603
  {@render removeIcon()}
564
604
  {:else}
565
- <CrossIcon width="15px" />
605
+ <Icon icon="Cross" width="15px" />
566
606
  {/if}
567
607
  </button>
568
608
  {/if}
@@ -581,25 +621,29 @@ function portal(node, params) {
581
621
  bind:this={ul_options}
582
622
  style={ulOptionsStyle}
583
623
  >
584
- {#each matchingOptions.slice(0, Math.max(0, maxOptions ?? 0) || Infinity) as optionItem, idx (duplicates ? [key(optionItem), idx] : key(optionItem))}
624
+ {#each matchingOptions.slice(0, Math.max(0, maxOptions ?? 0) || Infinity) as
625
+ optionItem,
626
+ idx
627
+ (duplicates ? [key(optionItem), idx] : key(optionItem))
628
+ }
585
629
  {@const {
586
- label,
587
- disabled = null,
588
- title = null,
589
- selectedTitle = null,
590
- disabledTitle = defaultDisabledTitle,
591
- } = optionItem instanceof Object ? optionItem : { label: optionItem }}
630
+ label,
631
+ disabled = null,
632
+ title = null,
633
+ selectedTitle = null,
634
+ disabledTitle = defaultDisabledTitle,
635
+ } = optionItem instanceof Object ? optionItem : { label: optionItem }}
592
636
  {@const active = activeIndex === idx}
593
637
  {@const optionStyle =
594
- [get_style(optionItem, `option`), liOptionStyle].filter(Boolean).join(` `) ||
595
- null}
638
+ [get_style(optionItem, `option`), liOptionStyle].filter(Boolean).join(
639
+ ` `,
640
+ ) ||
641
+ null}
596
642
  <li
597
643
  onclick={(event) => {
598
644
  if (!disabled) add(optionItem, event)
599
645
  }}
600
- title={disabled
601
- ? disabledTitle
602
- : (is_selected(label) && selectedTitle) || title}
646
+ title={disabled ? disabledTitle : (is_selected(label) && selectedTitle) || title}
603
647
  class:selected={is_selected(label)}
604
648
  class:active
605
649
  class:disabled
@@ -622,14 +666,14 @@ function portal(node, params) {
622
666
  >
623
667
  {#if option}
624
668
  {@render option({
625
- option: optionItem,
626
- idx,
627
- })}
669
+ option: optionItem,
670
+ idx,
671
+ })}
628
672
  {:else if children}
629
673
  {@render children({
630
- option: optionItem,
631
- idx,
632
- })}
674
+ option: optionItem,
675
+ idx,
676
+ })}
633
677
  {:else if parseLabelsAsHtml}
634
678
  {@html get_label(optionItem)}
635
679
  {:else}
@@ -641,15 +685,15 @@ function portal(node, params) {
641
685
  {@const text_input_is_duplicate = selected.map(get_label).includes(searchText)}
642
686
  {@const is_dupe = !duplicates && text_input_is_duplicate && `dupe`}
643
687
  {@const can_create = Boolean(allowUserOptions && createOptionMsg) && `create`}
644
- {@const no_match =
645
- Boolean(matchingOptions?.length == 0 && noMatchingOptionsMsg) && `no-match`}
688
+ {@const no_match = Boolean(matchingOptions?.length == 0 && noMatchingOptionsMsg) &&
689
+ `no-match`}
646
690
  {@const msgType = is_dupe || can_create || no_match}
647
691
  {#if msgType}
648
692
  {@const msg = {
649
- dupe: duplicateOptionMsg,
650
- create: createOptionMsg,
651
- 'no-match': noMatchingOptionsMsg,
652
- }[msgType]}
693
+ dupe: duplicateOptionMsg,
694
+ create: createOptionMsg,
695
+ 'no-match': noMatchingOptionsMsg,
696
+ }[msgType]}
653
697
  <li
654
698
  onclick={(event) => {
655
699
  if (msgType === `create` && allowUserOptions) {
@@ -667,10 +711,10 @@ function portal(node, params) {
667
711
  }
668
712
  }}
669
713
  title={msgType === `create`
670
- ? createOptionMsg
671
- : msgType === `dupe`
672
- ? duplicateOptionMsg
673
- : ``}
714
+ ? createOptionMsg
715
+ : msgType === `dupe`
716
+ ? duplicateOptionMsg
717
+ : ``}
674
718
  class:active={option_msg_is_active}
675
719
  onmouseover={() => (option_msg_is_active = true)}
676
720
  onfocus={() => (option_msg_is_active = true)}
@@ -678,9 +722,11 @@ function portal(node, params) {
678
722
  onblur={() => (option_msg_is_active = false)}
679
723
  role="option"
680
724
  aria-selected="false"
681
- class="user-msg {liUserMsgClass} {option_msg_is_active
725
+ class="
726
+ user-msg {liUserMsgClass} {option_msg_is_active
682
727
  ? liActiveUserMsgClass
683
- : ``}"
728
+ : ``}
729
+ "
684
730
  style:cursor={{
685
731
  dupe: `not-allowed`,
686
732
  create: `pointer`,
@@ -791,6 +837,12 @@ function portal(node, params) {
791
837
  cursor: inherit; /* needed for disabled state */
792
838
  border-radius: 0; /* reset ul.selected > li */
793
839
  }
840
+
841
+ /* When options are selected, placeholder is hidden in which case we minimize input width to avoid adding unnecessary width to div.multiselect */
842
+ :is(div.multiselect > ul.selected > input:not(:placeholder-shown)) {
843
+ min-width: 1px; /* Minimal width to remain interactive */
844
+ }
845
+
794
846
  /* don't wrap ::placeholder rules in :is() as it seems to be overpowered by browser defaults i.t.o. specificity */
795
847
  div.multiselect > ul.selected > input::placeholder {
796
848
  padding-left: 5pt;
@@ -820,7 +872,8 @@ function portal(node, params) {
820
872
  z-index: var(--sms-options-z-index, 3);
821
873
 
822
874
  overflow: auto;
823
- transition: all 0.2s; /* Consider if this transition is desirable with portal positioning */
875
+ transition: all
876
+ 0.2s; /* Consider if this transition is desirable with portal positioning */
824
877
  box-sizing: border-box;
825
878
  background: var(--sms-options-bg, white);
826
879
  max-height: var(--sms-options-max-height, 50vh);
@@ -1,10 +1,9 @@
1
- import type { MultiSelectProps } from './props';
2
- import type { Option as T } from './types';
1
+ import type { MultiSelectProps, Option as T } from './types';
3
2
  declare class __sveltets_Render<Option extends T> {
4
3
  props(): MultiSelectProps<T>;
5
4
  events(): {};
6
5
  slots(): {};
7
- bindings(): "value" | "selected" | "invalid" | "open" | "activeIndex" | "activeOption" | "form_input" | "input" | "matchingOptions" | "options" | "outerDiv" | "searchText";
6
+ bindings(): "open" | "input" | "value" | "selected" | "invalid" | "activeIndex" | "activeOption" | "form_input" | "matchingOptions" | "options" | "outerDiv" | "searchText";
8
7
  exports(): {};
9
8
  }
10
9
  interface $$IsomorphicComponent {
@@ -0,0 +1,100 @@
1
+ <script lang="ts">let { items = [], node = `nav`, current = ``, log = `errors`, nav_options = { replace_state: true, no_scroll: true }, titles = { prev: `&larr; Previous`, next: `Next &rarr;` }, onkeyup = ({ prev, next }) => ({
2
+ ArrowLeft: prev[0],
3
+ ArrowRight: next[0],
4
+ }), prev_snippet, children, between, next_snippet, ...rest } = $props();
5
+ // Convert items to consistent [key, value] format
6
+ let items_arr = $derived((items ?? []).map((itm) => (typeof itm === `string` ? [itm, itm] : itm)));
7
+ // Calculate prev/next items with wraparound
8
+ let idx = $derived(items_arr.findIndex(([key]) => key === current));
9
+ let prev = $derived(items_arr[idx - 1] ?? items_arr[items_arr.length - 1]);
10
+ let next = $derived(items_arr[idx + 1] ?? items_arr[0]);
11
+ // Validation and logging
12
+ $effect.pre(() => {
13
+ if (log !== `silent`) {
14
+ if (items_arr.length < 2 && log === `verbose`) {
15
+ console.warn(`PrevNext received ${items_arr.length} items - minimum of 2 expected`);
16
+ }
17
+ if (idx < 0 && log === `errors`) {
18
+ const valid = items_arr.map(([key]) => key);
19
+ console.error(`PrevNext received invalid current=${current}, expected one of ${valid}`);
20
+ }
21
+ }
22
+ });
23
+ function handle_keyup(event) {
24
+ if (!onkeyup)
25
+ return;
26
+ const key_map = onkeyup({ prev, next });
27
+ const to = key_map[event.key];
28
+ if (to) {
29
+ const { replace_state, no_scroll } = nav_options;
30
+ const [scroll_x, scroll_y] = no_scroll
31
+ ? [window.scrollX, window.scrollY]
32
+ : [0, 0];
33
+ const goto = window.history[replace_state ? `replaceState` : `pushState`];
34
+ goto.call(window.history, {}, ``, to); // Navigate using appropriate history method
35
+ if (no_scroll)
36
+ window.scrollTo(scroll_x, scroll_y); // Restore scroll position if needed
37
+ }
38
+ }
39
+ export {};
40
+ </script>
41
+
42
+ <svelte:window onkeyup={handle_keyup} />
43
+
44
+ {#if items_arr.length > 2}
45
+ <svelte:element this={node} class="prev-next" {...rest}>
46
+ <!-- ensures `prev` is a defined [key, value] tuple.
47
+ Due to prior normalization of the `items` prop, any defined `prev` item
48
+ is guaranteed to be a 2-element array except if `prev` is null.
49
+ -->
50
+ {#if prev?.length >= 2}
51
+ {#if prev_snippet}
52
+ {@render prev_snippet({ item: prev })}
53
+ {:else if children}
54
+ {@render children({ kind: `prev`, item: prev })}
55
+ {:else}
56
+ <div>
57
+ {#if titles.prev}<span>{@html titles.prev}</span>{/if}
58
+ <a href={prev[0]}>{prev[0]}</a>
59
+ </div>
60
+ {/if}
61
+ {/if}
62
+ {@render between?.()}
63
+ {#if next?.length >= 2}
64
+ {#if next_snippet}
65
+ {@render next_snippet({ item: next })}
66
+ {:else if children}
67
+ {@render children({ kind: `next`, item: next })}
68
+ {:else}
69
+ <div>
70
+ {#if titles.next}<span>{@html titles.next}</span>{/if}
71
+ <a href={next[0]}>{next[0]}</a>
72
+ </div>
73
+ {/if}
74
+ {/if}
75
+ </svelte:element>
76
+ {/if}
77
+
78
+ <style>
79
+ .prev-next {
80
+ display: flex;
81
+ list-style: none;
82
+ place-content: space-between;
83
+ gap: var(--prev-next-gap, 2em);
84
+ padding: var(--prev-next-padding, 0);
85
+ margin: var(--prev-next-margin, 3em auto);
86
+ }
87
+ .prev-next a {
88
+ color: var(--prev-next-color);
89
+ background: var(--prev-next-link-bg);
90
+ padding: var(--prev-next-link-padding);
91
+ border-radius: var(--prev-next-link-border-radius);
92
+ }
93
+ .prev-next span {
94
+ display: block;
95
+ margin: var(--prev-next-label-margin, 0 auto 1ex);
96
+ }
97
+ .prev-next > div:nth-child(2) {
98
+ text-align: right;
99
+ }
100
+ </style>
@@ -0,0 +1,48 @@
1
+ import type { Snippet } from 'svelte';
2
+ export type Item = string | [string, unknown];
3
+ declare class __sveltets_Render<T extends Item> {
4
+ props(): {
5
+ [key: string]: unknown;
6
+ items?: T[] | undefined;
7
+ node?: string;
8
+ current?: string;
9
+ log?: `verbose` | `errors` | `silent`;
10
+ nav_options?: {
11
+ replace_state: boolean;
12
+ no_scroll: boolean;
13
+ } | undefined;
14
+ titles?: {
15
+ prev: string;
16
+ next: string;
17
+ } | undefined;
18
+ onkeyup?: ((obj: {
19
+ prev: Item;
20
+ next: Item;
21
+ }) => Record<string, string>) | null | undefined;
22
+ prev_snippet?: Snippet<[{
23
+ item: Item;
24
+ }]> | undefined;
25
+ children?: Snippet<[{
26
+ kind: `prev` | `next`;
27
+ item: Item;
28
+ }]> | undefined;
29
+ between?: Snippet<[]>;
30
+ next_snippet?: Snippet<[{
31
+ item: Item;
32
+ }]> | undefined;
33
+ };
34
+ events(): {};
35
+ slots(): {};
36
+ bindings(): "";
37
+ exports(): {};
38
+ }
39
+ interface $$IsomorphicComponent {
40
+ new <T extends Item>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T>['props']>, ReturnType<__sveltets_Render<T>['events']>, ReturnType<__sveltets_Render<T>['slots']>> & {
41
+ $$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
42
+ } & ReturnType<__sveltets_Render<T>['exports']>;
43
+ <T extends Item>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {}): ReturnType<__sveltets_Render<T>['exports']>;
44
+ z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
45
+ }
46
+ declare const PrevNext: $$IsomorphicComponent;
47
+ type PrevNext<T extends Item> = InstanceType<typeof PrevNext<T>>;
48
+ export default PrevNext;
@@ -0,0 +1,67 @@
1
+ <script lang="ts">// get the label key from an option object or the option itself if it's a string or number
2
+ const get_label = (op) => {
3
+ if (op instanceof Object) {
4
+ if (op.label === undefined) {
5
+ console.error(`RadioButton option ${JSON.stringify(op)} is an object but has no label key`);
6
+ }
7
+ return op.label;
8
+ }
9
+ return op;
10
+ };
11
+ let { options, selected = $bindable(), id = null, name = null, disabled = false, required = false, aria_label = null, onclick, onchange, oninput, option_snippet, children, ...rest } = $props();
12
+ export {};
13
+ </script>
14
+
15
+ <div {id} {...rest}>
16
+ {#each options as option (JSON.stringify(option))}
17
+ {@const label = get_label(option)}
18
+ {@const active = selected && get_label(option) === get_label(selected)}
19
+ <label class:active aria-label={aria_label}>
20
+ <input
21
+ type="radio"
22
+ value={option}
23
+ {name}
24
+ {disabled}
25
+ {required}
26
+ bind:group={selected}
27
+ {onchange}
28
+ {oninput}
29
+ {onclick}
30
+ />
31
+ {#if option_snippet}
32
+ {@render option_snippet({ option, selected, active })}
33
+ {:else if children}
34
+ {@render children({ option, selected, active })}
35
+ {:else}<span>{label}</span>{/if}
36
+ </label>
37
+ {/each}
38
+ </div>
39
+
40
+ <style>
41
+ div {
42
+ max-width: max-content;
43
+ overflow: hidden;
44
+ height: fit-content;
45
+ display: var(--radio-btn-display, inline-flex);
46
+ border-radius: var(--radio-btn-border-radius, 0.5em);
47
+ }
48
+ input {
49
+ display: none;
50
+ }
51
+ span {
52
+ cursor: pointer;
53
+ display: inline-block;
54
+ color: var(--radio-btn-color, white);
55
+ padding: var(--radio-btn-padding, 2pt 5pt);
56
+ background: var(--radio-btn-bg, black);
57
+ transition: var(--radio-btn-transition, background 0.3s, transform 0.3s);
58
+ }
59
+ label:not(.active) span:hover {
60
+ background: var(--radio-btn-hover-bg, cornflowerblue);
61
+ color: var(--radio-btn-hover-color, white);
62
+ }
63
+ label.active span {
64
+ box-shadow: var(--radio-btn-checked-shadow, inset 0 0 1em -3pt black);
65
+ background: var(--radio-btn-checked-bg, darkcyan);
66
+ }
67
+ </style>