Skip to content

Commit

Permalink
Add props style, inputStyle, liOptionStyle, liSelectedStyle, …
Browse files Browse the repository at this point in the history
…`ulSelectedStyle`, `ulOptionsStyle` (#279)

* bump sveltekit to v2 + update other deps

* add props style + inputStyle + liOptionStyle + liSelectedStyle + ulSelectedStyle + ulOptionsStyle

* document new style props in readme

* add test 'MultiSelect applies style props to the correct element'
  • Loading branch information
janosh authored Jan 14, 2024
1 parent dd67fb1 commit 63f8839
Show file tree
Hide file tree
Showing 8 changed files with 134 additions and 56 deletions.
42 changes: 21 additions & 21 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,37 +23,37 @@
"update-coverage": "vitest tests/unit --run --coverage && npx istanbul-badges-readme"
},
"dependencies": {
"svelte": "4.2.3"
"svelte": "4.2.8"
},
"devDependencies": {
"@iconify/svelte": "^3.1.4",
"@playwright/test": "^1.39.0",
"@sveltejs/adapter-static": "^2.0.3",
"@sveltejs/kit": "^1.27.6",
"@sveltejs/package": "2.2.2",
"@sveltejs/vite-plugin-svelte": "2.5.2",
"@typescript-eslint/eslint-plugin": "^6.11.0",
"@typescript-eslint/parser": "^6.11.0",
"@vitest/coverage-v8": "^0.34.6",
"eslint": "^8.53.0",
"eslint-plugin-svelte": "^2.35.0",
"@iconify/svelte": "^3.1.6",
"@playwright/test": "^1.40.1",
"@sveltejs/adapter-static": "^3.0.1",
"@sveltejs/kit": "^2.3.2",
"@sveltejs/package": "2.2.5",
"@sveltejs/vite-plugin-svelte": "3.0.1",
"@typescript-eslint/eslint-plugin": "^6.18.1",
"@typescript-eslint/parser": "^6.18.1",
"@vitest/coverage-v8": "^1.2.0",
"eslint": "^8.56.0",
"eslint-plugin-svelte": "^2.35.1",
"hastscript": "^8.0.0",
"highlight.js": "^11.9.0",
"jsdom": "^22.1.0",
"jsdom": "^23.2.0",
"mdsvex": "^0.11.0",
"mdsvexamples": "^0.4.1",
"prettier": "^3.1.0",
"prettier-plugin-svelte": "^3.1.0",
"prettier": "^3.2.1",
"prettier-plugin-svelte": "^3.1.2",
"rehype-autolink-headings": "^7.1.0",
"rehype-slug": "^6.0.0",
"svelte-check": "^3.6.0",
"svelte-preprocess": "^5.1.0",
"svelte-check": "^3.6.3",
"svelte-preprocess": "^5.1.3",
"svelte-toc": "^0.5.6",
"svelte-zoo": "^0.4.9",
"svelte2tsx": "^0.6.25",
"typescript": "5.2.2",
"vite": "^4.5.0",
"vitest": "^0.34.6"
"svelte2tsx": "^0.7.0",
"typescript": "5.3.3",
"vite": "^5.0.11",
"vitest": "^1.2.0"
},
"keywords": [
"svelte",
Expand Down
36 changes: 36 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -229,12 +229,30 @@ Full list of props/bindable variables for this component. The `Option` type you
The `inputmode` attribute hints at the type of data the user may enter. Values like `'numeric' | 'tel' | 'email'` allow mobile browsers to display an appropriate virtual on-screen keyboard. See [MDN](https://developer.mozilla.org/docs/Web/HTML/Global_attributes/inputmode) for details. If you want to suppress the on-screen keyboard to leave full-screen real estate for the dropdown list of options, set `inputmode="none"`.
1. ```ts
inputStyle: string | null = null
```
One-off CSS rules applied to the `<input>` element.
1. ```ts
invalid: boolean = false
```
If `required = true, 1, 2, ...` and user tries to submit form but `selected = []` is empty/`selected.length < required`, `invalid` is automatically set to `true` and CSS class `invalid` applied to the top-level `div.multiselect`. `invalid` class is removed as soon as any change to `selected` is registered. `invalid` can also be controlled externally by binding to it `<MultiSelect bind:invalid />` and setting it to `true` based on outside events or custom validation.
1. ```ts
liOptionStyle: string | null = null
```
One-off CSS rules applied to the `<li>` elements that wrap the dropdown options.
1. ```ts
liSelectedStyle: string | null = null
```
One-off CSS rules applied to the `<li>` elements that wrap the selected options.
1. ```ts
loading: boolean = false
```
Expand Down Expand Up @@ -381,6 +399,24 @@ Full list of props/bindable variables for this component. The `Option` type you
Whether selected options are draggable so users can change their order.
1. ```ts
style: string | null = null
```
One-off CSS rules applied to the outer `<div class="multiselect">` that wraps the whole component for passing one-off CSS.
1. ```ts
ulSelectedStyle: string | null = null
```
One-off CSS rules applied to the `<ul class="selected">` that wraps the list of selected options.
1. ```ts
ulOptionsStyle: string | null = null
```
One-off CSS rules applied to the `<ul class="options">` that wraps the list of selected options.
1. ```ts
value: Option | Option[] | null = null
```
Expand Down
41 changes: 27 additions & 14 deletions src/lib/MultiSelect.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -35,20 +35,23 @@
export let id: string | null = null
export let input: HTMLInputElement | null = null
export let inputClass: string = ``
export let inputStyle: string | null = null
export let inputmode: string | null = null
export let invalid: boolean = false
export let liActiveOptionClass: string = ``
export let liActiveUserMsgClass: string = ``
export let liOptionClass: string = ``
export let liOptionStyle: string | null = null
export let liSelectedClass: string = ``
export let liSelectedStyle: string | null = null
export let liUserMsgClass: string = ``
export let loading: boolean = false
export let matchingOptions: Option[] = []
export let maxOptions: number | undefined = undefined
export let maxSelect: number | null = null // null means there is no upper limit for selected.length
export let maxSelectMsg: ((current: number, max: number) => string) | null = (
current: number,
max: number
max: number,
) => (max > 1 ? `${current}/${max}` : ``)
export let maxSelectMsgClass: string = ``
export let name: string | null = null
Expand All @@ -72,8 +75,11 @@
.slice(0, maxSelect ?? undefined) ?? [] // don't allow more than maxSelect preselected options
export let sortSelected: boolean | ((op1: Option, op2: Option) => number) = false
export let selectedOptionsDraggable: boolean = !sortSelected
export let style: string | null = null
export let ulOptionsClass: string = ``
export let ulSelectedClass: string = ``
export let ulSelectedStyle: string | null = null
export let ulOptionsStyle: string | null = null
export let value: Option | Option[] | null = null
const selected_to_value = (selected: Option[]) => {
Expand Down Expand Up @@ -106,42 +112,42 @@
}
if (maxSelect !== null && maxSelect < 1) {
console.error(
`MultiSelect's maxSelect must be null or positive integer, got ${maxSelect}`
`MultiSelect's maxSelect must be null or positive integer, got ${maxSelect}`,
)
}
if (!Array.isArray(selected)) {
console.error(
`MultiSelect's selected prop should always be an array, got ${selected}`
`MultiSelect's selected prop should always be an array, got ${selected}`,
)
}
if (maxSelect && typeof required === `number` && required > maxSelect) {
console.error(
`MultiSelect maxSelect=${maxSelect} < required=${required}, makes it impossible for users to submit a valid form`
`MultiSelect maxSelect=${maxSelect} < required=${required}, makes it impossible for users to submit a valid form`,
)
}
if (parseLabelsAsHtml && allowUserOptions) {
console.warn(
`Don't combine parseLabelsAsHtml and allowUserOptions. It's susceptible to XSS attacks!`
`Don't combine parseLabelsAsHtml and allowUserOptions. It's susceptible to XSS attacks!`,
)
}
if (sortSelected && selectedOptionsDraggable) {
console.warn(
`MultiSelect's sortSelected and selectedOptionsDraggable should not be combined as any ` +
`user re-orderings of selected options will be undone by sortSelected on component re-renders.`
`user re-orderings of selected options will be undone by sortSelected on component re-renders.`,
)
}
if (allowUserOptions && !createOptionMsg && createOptionMsg !== null) {
console.error(
`MultiSelect has allowUserOptions=${allowUserOptions} but createOptionMsg=${createOptionMsg} is falsy. ` +
`This prevents the "Add option" <span> from showing up, resulting in a confusing user experience.`
`This prevents the "Add option" <span> from showing up, resulting in a confusing user experience.`,
)
}
if (
maxOptions &&
(typeof maxOptions != `number` || maxOptions < 0 || maxOptions % 1 != 0)
) {
console.error(
`MultiSelect's maxOptions must be undefined or a positive integer, got ${maxOptions}`
`MultiSelect's maxOptions must be undefined or a positive integer, got ${maxOptions}`,
)
}
Expand All @@ -154,7 +160,7 @@
(opt) =>
filterFunc(opt, searchText) &&
// remove already selected options from dropdown list unless duplicate selections are allowed
(!selected.map(key).includes(key(opt)) || duplicates)
(!selected.map(key).includes(key(opt)) || duplicates),
)
// raise if matchingOptions[activeIndex] does not yield a value
Expand Down Expand Up @@ -261,8 +267,8 @@
if (option === undefined) {
return console.error(
`Multiselect can't remove selected option ${JSON.stringify(
to_remove
)}, not found in selected list`
to_remove,
)}, not found in selected list`,
)
}
Expand Down Expand Up @@ -474,6 +480,7 @@
data-id={id}
role="searchbox"
tabindex="-1"
{style}
>
<!-- form control input invisible to the user, only purpose is to abort form submission if this component fails data validation -->
<!-- bind:value={selected} prevents form submission if required prop is true and no options are selected -->
Expand Down Expand Up @@ -502,7 +509,11 @@
<slot name="expand-icon" {open}>
<ExpandIcon width="15px" style="min-width: 1em; padding: 0 1pt; cursor: pointer;" />
</slot>
<ul class="selected {ulSelectedClass}" aria-label="selected options">
<ul
class="selected {ulSelectedClass}"
aria-label="selected options"
style={ulSelectedStyle}
>
{#each selected as option, idx (duplicates ? [key(option), idx] : key(option))}
<li
class={liSelectedClass}
Expand All @@ -515,7 +526,7 @@
on:dragenter={() => (drag_idx = idx)}
on:dragover|preventDefault
class:active={drag_idx === idx}
style={get_style(option, `selected`)}
style="{get_style(option, `selected`)} {liSelectedStyle}"
>
<!-- on:dragover|preventDefault needed for the drop to succeed https://stackoverflow.com/a/31085796 -->
<slot name="selected" {option} {idx}>
Expand Down Expand Up @@ -544,6 +555,7 @@
{/each}
<input
class={inputClass}
style={inputStyle}
bind:this={input}
bind:value={searchText}
on:mouseup|self|stopPropagation={open_dropdown}
Expand Down Expand Up @@ -626,6 +638,7 @@
aria-expanded={open}
aria-disabled={disabled ? `true` : null}
bind:this={ul_options}
style={ulOptionsStyle}
>
{#each matchingOptions.slice(0, Math.max(0, maxOptions ?? 0) || Infinity) as option, idx}
{@const {
Expand Down Expand Up @@ -658,7 +671,7 @@
on:blur={() => (activeIndex = null)}
role="option"
aria-selected="false"
style={get_style(option, `option`)}
style="{get_style(option, `option`)} {liOptionStyle}"
>
<slot name="option" {option} {idx}>
<slot {option} {idx}>
Expand Down
12 changes: 8 additions & 4 deletions src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,26 +15,30 @@ export const get_label = (opt: Option) => {
return `${opt}`
}

// this function is used extract CSS strings from a {selected, option} style object to be used in the style attribute of the option
// if the style is a string, it will be returned as is
export function get_style(
option: { style?: OptionStyle; [key: string]: unknown } | string | number,
key: 'selected' | 'option' | null = null,
) {
if (!option?.style) return null
let css_str = ``
if (![`selected`, `option`, null].includes(key)) {
console.error(`MultiSelect: Invalid key=${key} for get_style`)
return
}
if (typeof option == `object` && option.style) {
if (typeof option.style == `string`) {
return option.style
css_str = option.style
}
if (typeof option.style == `object`) {
if (key && key in option.style) return option.style[key]
if (key && key in option.style) return option.style[key] ?? ``
else {
console.error(
`Invalid style object for option=${JSON.stringify(option)}`,
)
}
}
}
// ensure css_str ends with a semicolon
if (css_str.trim() && !css_str.trim().endsWith(`;`)) css_str += `;`
return css_str
}
1 change: 0 additions & 1 deletion src/site/Examples.svx
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,6 @@

<style>
label:not(:first-of-type) {
border-top: 0.5px solid #ccc;
padding-top: 2em;
display: block;
}
Expand Down
4 changes: 3 additions & 1 deletion svelte.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,9 @@ export default {

prerender: {
handleMissingId: ({ id }) => {
if (![`🔣-props`].includes(id)) throw id
// list of ok-to-be-missing IDs
if ([`🔣-props`].includes(id)) return
throw `Missing ID: ${id}`
},
},
},
Expand Down
Loading

0 comments on commit 63f8839

Please sign in to comment.