Skip to content

Commit

Permalink
Allow preventing duplicate options when allowUserOptions is thruthy (#…
Browse files Browse the repository at this point in the history
…132)

* add props duplicates, duplicateFunc and duplicateOptionMsg to control how duplicates are handled when allowUserOptions is truthy

add CSS variable
background: var(--sms-remove-btn-hover-bg, rgba(0, 0, 0, 0.2))
rename CSS variable
--sms-button-hover-color to --sms-remove-btn-hover-color

* document props duplicates, duplicateFunc and duplicateOptionMsg

* fix label `for` attr in Example.svelte, give all labels cursor: pointer

since they open multiselect on click

* add test 'shows duplicateOptionMsg when searchText is already selected ' for new duplicate props

* change default remove button icon to Google Material Icons round-clear

https://api.iconify.design/ic:round-clear.svg
  • Loading branch information
janosh committed Oct 13, 2022
1 parent 04241f0 commit 5085571
Show file tree
Hide file tree
Showing 6 changed files with 99 additions and 12 deletions.
27 changes: 24 additions & 3 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,26 @@ import type { Option } from 'svelte-multiselect'

Tooltip text to display on hover when the component is in `disabled` state.

<!-- prettier-ignore -->
1. ```ts
duplicateFunc: (op1: Option, op2: Option) => boolean = (op1, op2) =>
`${get_label(op1)}`.toLowerCase() === `${get_label(op2)}`.toLowerCase()
```

This option determines when two options are considered duplicates. Defaults to case-insensitive equality comparison after string coercion (looking only at the `label` key of object options). I.e. the default `duplicateFunc` considers `'Foo' == 'foo'`, `'42' == 42` and ``{ label: `Foo`, value: 0 } == { label: `foo`, value: 42 }``.

1. ```ts
duplicates: boolean = false
```

Whether to allow users to select duplicate options. Applies only to the selected item list, not the options dropdown. Keeping that free of duplicates is left to developer. The selected item list can have duplicates if `allowUserOptions` is truthy, `duplicates` is ` true` and users create the same option multiple times. Use `duplicateOptionMsg` to customize the message shown to user if `duplicates` is `false` and users attempt this and `duplicateFunc` to customize when a pair of options is considered a duplicate.

1. ```ts
duplicateOptionMsg: string = `This option is already selected`
```

Text to display to users when `allowUserOptions` is truthy and they try to create a new option that's already selected.

1. ```ts
filterFunc = (op: Option, searchText: string): boolean => {
if (!searchText) return true
Expand Down Expand Up @@ -290,13 +310,13 @@ import type { Option } from 'svelte-multiselect'
selectedLabels: (string | number)[] | string | number | null = []
```
Labels of currently selected options. Exposed just for convenience, equivalent to `selected.map(op => op.label)` when options are objects. If options are simple strings, `selected === selectedLabels`. Supports binding but is read-only, i.e. since this value is reactive to `selected`, you cannot control `selected` by changing `bind:selectedLabels`. If `maxSelect={1}`, selectedLabels will not be an array but a single `string | number` or `null` if no options are selected.
Labels of currently selected options. Exposed just for convenience, equivalent to `selected.map(op => op.label)` when options are objects. If options are simple strings (or numbers), `selected === selectedLabels`. Supports binding but is read-only, i.e. since this value is reactive to `selected`, you cannot control `selected` by changing `bind:selectedLabels`. If `maxSelect={1}`, selectedLabels will not be an array but a single `string | number` or `null` if no options are selected.
1. ```ts
selectedValues: unknown[] | unknown | null = []
```
Values of currently selected options. Exposed just for convenience, equivalent to `selected.map(op => op.value)` when options are objects. If options are simple strings, `selected === selectedValues`. Supports binding but is read-only, i.e. since this value is reactive to `selected`, you cannot control `selected` by changing `bind:selectedValues`. If `maxSelect={1}`, selectedLabels will not be an array but a single value or `null` if no options are selected.
Values of currently selected options. Exposed just for convenience, equivalent to `selected.map(op => op.value)` when options are objects. If options are simple strings (or numbers), `selected === selectedValues`. Supports binding but is read-only, i.e. since this value is reactive to `selected`, you cannot control `selected` by changing `bind:selectedValues`. If `maxSelect={1}`, selectedLabels will not be an array but a single value or `null` if no options are selected.
1. ```ts
sortSelected: boolean | ((op1: Option, op2: Option) => number) = false
Expand Down Expand Up @@ -460,7 +480,8 @@ If you only want to make small adjustments, you can pass the following CSS varia
- `padding: var(--sms-selected-li-padding, 1pt 5pt)`: Height of selected options.
- `color: var(--sms-selected-text-color, var(--sms-text-color))`: Text color for selected options.
- `ul.selected > li button:hover, button.remove-all:hover, button:focus`
- `color: var(--sms-button-hover-color, lightskyblue)`: Color of the remove-icon buttons for removing all or individual selected options when in `:focus` or `:hover` state.
- `color: var(--sms-remove-btn-hover-color, lightskyblue)`: Color of the remove-icon buttons for removing all or individual selected options when in `:focus` or `:hover` state.
- `background: var(--sms-remove-btn-hover-bg, rgba(0, 0, 0, 0.2))`: Background for hovered remove buttons.
- `div.multiselect > ul.options`
- `background: var(--sms-options-bg, white)`: Background of dropdown list.
- `max-height: var(--sms-options-max-height, 50vh)`: Maximum height of options dropdown.
Expand Down
1 change: 1 addition & 0 deletions src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ ul {
}
label {
font-weight: bold;
cursor: pointer;
}

table {
Expand Down
2 changes: 1 addition & 1 deletion src/components/Examples.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@
<section>
<h3>Slot Components</h3>

<label for="food-select">
<label for="color-select">
Color select using the <code>'selected'</code> and <code>'option'</code> slot components
to render colors.
</label>
Expand Down
33 changes: 27 additions & 6 deletions src/lib/MultiSelect.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@
export let defaultDisabledTitle: string = `This option is disabled`
export let disabled: boolean = false
export let disabledInputTitle: string = `This input is disabled`
// case-insensitive equality comparison after string coercion (looking only at the `label` key of object options)
export let duplicateFunc: (op1: Option, op2: Option) => boolean = (op1, op2) =>
`${get_label(op1)}`.toLowerCase() === `${get_label(op2)}`.toLowerCase()
export let duplicateOptionMsg: string = `This option is already selected`
export let duplicates: boolean = false // whether to allow duplicate options
export let filterFunc = (op: Option, searchText: string): boolean => {
if (!searchText) return true
return `${get_label(op)}`.toLowerCase().includes(searchText.toLowerCase())
Expand Down Expand Up @@ -120,8 +125,14 @@
// add an option to selected list
function add(label: string | number, event: Event) {
if (maxSelect && maxSelect > 1 && _selected.length >= maxSelect) wiggle = true
// to prevent duplicate selection, we could add `&& !selectedLabels.includes(label)`
if (maxSelect === null || maxSelect === 1 || _selected.length < maxSelect) {
if (!isNaN(Number(label)) && typeof _selectedLabels[0] === `number`)
label = Number(label) // convert to number if possible
const is_duplicate = _selected.some((option) => duplicateFunc(option, label))
if (
(maxSelect === null || maxSelect === 1 || _selected.length < maxSelect) &&
(duplicates || !is_duplicate)
) {
// first check if we find option in the options list
let option = options.find((op) => get_label(op) === label)
Expand All @@ -147,6 +158,9 @@
}
if (allowUserOptions === `append`) options = [...options, option]
}
if (option === undefined) {
throw `Run time error, option with label ${label} not found in options list`
}
searchText = `` // reset search string on selection
if ([``, undefined, null].includes(option)) {
console.error(
Expand Down Expand Up @@ -352,7 +366,9 @@
type="button"
title="{removeBtnTitle} {get_label(option)}"
>
<slot name="remove-icon"><CrossIcon width="15px" /></slot>
<slot name="remove-icon">
<CrossIcon width="15px" />
</slot>
</button>
{/if}
</li>
Expand Down Expand Up @@ -416,7 +432,9 @@
on:mouseup|stopPropagation={remove_all}
on:keydown={if_enter_or_space(remove_all)}
>
<slot name="remove-icon"><CrossIcon width="15px" /></slot>
<slot name="remove-icon">
<CrossIcon width="15px" />
</slot>
</button>
{/if}
{/if}
Expand Down Expand Up @@ -476,7 +494,9 @@
on:blur={() => (add_option_msg_is_active = false)}
aria-selected="false"
>
{addOptionMsg}
{!duplicates && _selected.some((option) => duplicateFunc(option, searchText))
? duplicateOptionMsg
: addOptionMsg}
</li>
{:else}
<span>{noOptionsMsg}</span>
Expand Down Expand Up @@ -553,7 +573,8 @@
ul.selected > li button:hover,
button.remove-all:hover,
button:focus {
color: var(--sms-button-hover-color, lightskyblue);
color: var(--sms-remove-btn-hover-color, lightskyblue);
background: var(--sms-remove-btn-hover-bg, rgba(0, 0, 0, 0.2));
}
& input {
Expand Down
5 changes: 3 additions & 2 deletions src/lib/icons/Cross.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<svg {...$$props} viewBox="0 0 20 20" fill="currentColor">
<svg {...$$props} viewBox="0 0 24 24" fill="currentColor">
<path
d="M10 1.6a8.4 8.4 0 100 16.8 8.4 8.4 0 000-16.8zm4.789 11.461L13.06 14.79 10 11.729l-3.061 3.06L5.21 13.06 8.272 10 5.211 6.939 6.94 5.211 10 8.271l3.061-3.061 1.729 1.729L11.728 10l3.061 3.061z"
d="M18.3 5.71a.996.996 0 0 0-1.41 0L12 10.59L7.11 5.7A.996.996 0 1 0 5.7 7.11L10.59 12L5.7 16.89a.996.996 0 1 0 1.41 1.41L12 13.41l4.89 4.89a.996.996 0 1 0 1.41-1.41L13.41 12l4.89-4.89c.38-.38.38-1.02 0-1.4z"
/>
</svg>
<!-- https://api.iconify.design/ic:round-clear.svg -->
43 changes: 43 additions & 0 deletions tests/unit/multiselect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -441,4 +441,47 @@ describe(`MultiSelect`, () => {
document.querySelector(`div.multiselect ul.options.hidden`)
).toBeInstanceOf(HTMLUListElement)
})

describe.each([
[[`1`, `2`, `3`], [`1`]], // test string options
[[1, 2, 3], [1]], // test number options
])(
`shows duplicateOptionMsg when searchText is already selected for options=%j`,
(options, selected) => {
test.each([
[true, `Create this option...`],
[false, `Custom duplicate option message`],
])(
`allowUserOptions=true, duplicates=%j`,
async (duplicates, duplicateOptionMsg) => {
new MultiSelect({
target: document.body,
props: {
options,
allowUserOptions: true,
duplicates,
duplicateOptionMsg,
selected,
},
})

const input = document.querySelector(
`div.multiselect ul.selected input`
)
if (!input) throw new Error(`input not found`)

input.value = selected[0]
input.dispatchEvent(new InputEvent(`input`))
await sleep()

const dropdown = document.querySelector(`div.multiselect ul.options`)

const fail_msg = `options=${options}, selected=${selected}, duplicates=${duplicates}, duplicateOptionMsg=${duplicateOptionMsg}`
expect(dropdown?.textContent?.trim(), fail_msg).toBe(
duplicateOptionMsg
)
}
)
}
)
})

0 comments on commit 5085571

Please sign in to comment.