Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add key style to ObjectOption for per-option inline CSS #252

Merged
merged 8 commits into from
Jul 13, 2023
Merged
2 changes: 2 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,5 @@ on:
jobs:
tests:
uses: janosh/workflows/.github/workflows/npm-test-release.yml@main
with:
install-cmd: npm install -f
28 changes: 10 additions & 18 deletions src/lib/MultiSelect.svelte
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
<script lang="ts">
import { createEventDispatcher, tick } from 'svelte'
import { flip } from 'svelte/animate'
import CircleSpinner from './CircleSpinner.svelte'
import Wiggle from './Wiggle.svelte'
import { CrossIcon, DisabledIcon, ExpandIcon } from './icons'
import type { DispatchEvents, MultiSelectEvents, Option as T } from './types'
import { createEventDispatcher, tick } from 'svelte'
import { flip } from 'svelte/animate'
import CircleSpinner from './CircleSpinner.svelte'
import Wiggle from './Wiggle.svelte'
import { CrossIcon, DisabledIcon, ExpandIcon } from './icons'
import type { DispatchEvents, MultiSelectEvents, Option as T } from './types'
import { get_label, get_style } from './utils'
type Option = $$Generic<T>

export let activeIndex: number | null = null
Expand Down Expand Up @@ -73,18 +74,6 @@
export let ulSelectedClass: string = ``
export let value: Option | Option[] | null = null

// get the label key from an option object or the option itself if it's a string or number
export const get_label = (opt: T) => {
if (opt instanceof Object) {
if (opt.label === undefined) {
console.error(
`MultiSelect option ${JSON.stringify(opt)} is an object but has no label key`
)
}
return opt.label
}
return `${opt}`
}

const selected_to_value = (selected: Option[]) => {
value = maxSelect === 1 ? selected[0] ?? null : selected
Expand Down Expand Up @@ -459,6 +448,7 @@
// eslint-disable-next-line no-undef
CSS.highlights.set(`sms-search-matches`, new Highlight(...ranges.flat()))
}

</script>

<svelte:window
Expand Down Expand Up @@ -520,6 +510,7 @@
on:dragenter={() => (drag_idx = idx)}
on:dragover|preventDefault
class:active={drag_idx === idx}
style={get_style(option, `selected`)}
>
<!-- on:dragover|preventDefault needed for the drop to succeed https://stackoverflow.com/a/31085796 -->
<slot name="selected" {option} {idx}>
Expand Down Expand Up @@ -662,6 +653,7 @@
on:blur={() => (activeIndex = null)}
role="option"
aria-selected="false"
style={get_style(option,`option`)}
>
<slot name="option" {option} {idx}>
<slot {option} {idx}>
Expand Down
5 changes: 5 additions & 0 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
export type Option = string | number | ObjectOption

// single CSS string or an object with keys 'option' and 'selected', each a string,
// which only apply to the dropdown list and list of selected options, respectively
export type OptionStyle = string | { option: string; selected: string }

export type ObjectOption = {
label: string | number // user-displayed text
value?: unknown // associated value, can be anything incl. objects (defaults to label if undefined)
Expand All @@ -8,6 +12,7 @@ export type ObjectOption = {
preselected?: boolean // make this option selected on page load (before any user interaction)
disabledTitle?: string // override the default disabledTitle = 'This option is disabled'
selectedTitle?: string // tooltip to display when this option is selected and hovered
style?: OptionStyle
[key: string]: unknown // allow any other keys users might want
}

Expand Down
40 changes: 40 additions & 0 deletions src/lib/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type { Option, OptionStyle } from './types'

// get the label key from an option object or the option itself if it's a string or number
export const get_label = (opt: Option) => {
if (opt instanceof Object) {
if (opt.label === undefined) {
console.error(
`MultiSelect option ${JSON.stringify(
opt,
)} is an object but has no label key`,
)
}
return opt.label
}
return `${opt}`
}

export function get_style(
option: { style?: OptionStyle; [key: string]: unknown } | string | number,
key: 'selected' | 'option' | null = null,
) {
if (!option?.style) return null
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
}
if (typeof option.style == `object`) {
if (key && key in option.style) return option.style[key]
else {
console.error(
`Invalid style object for option=${JSON.stringify(option)}`,
)
}
}
}
}
9 changes: 8 additions & 1 deletion src/routes/(demos)/ui/+page.svx
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,17 @@
<script>
import MultiSelect from '$lib'
import { foods } from '$site/options'

function random_color() {
const r = Math.floor(Math.random() * 255)
const g = Math.floor(Math.random() * 255)
const b = Math.floor(Math.random() * 255)
return `rgb(${r}, ${g}, ${b})`
}
</script>

<MultiSelect
options={foods}
options={foods.map(label => ({ label, style: `background-color: ${random_color()}` }))}
placeholder="Pick your favorite foods"
removeAllTitle="Remove all foods"
invalid
Expand Down
90 changes: 89 additions & 1 deletion tests/unit/MultiSelect.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import MultiSelect, { type MultiSelectEvents, type Option } from '$lib'
import { get_label, get_style } from '$lib/utils'
import { tick } from 'svelte'
import { describe, expect, test, vi } from 'vitest'
import { doc_query } from '.'
Expand Down Expand Up @@ -532,7 +533,7 @@ test.each([
[[{ label: `foo` }, { label: `bar` }, { label: `baz` }]],
[[{ label: `foo`, value: 1, key: `whatever` }]],
])(`single remove button removes 1 selected option`, async (options) => {
const { get_label } = new MultiSelect({
new MultiSelect({
target: document.body,
props: { options, selected: [...options] },
})
Expand Down Expand Up @@ -1181,3 +1182,90 @@ test.each([[true], [-1], [3.5], [`foo`], [{}]])(
)
},
)

const css_str = `test-style`

test.each([
// Invalid key cases
[css_str, `invalid`, `MultiSelect: Invalid key=invalid for get_style`],
// Valid key cases
[css_str, `selected`, css_str],
[css_str, `option`, css_str],
[css_str, null, css_str],
// Object style cases
[
{ selected: `selected-style`, option: `option-style` },
`selected`,
`selected-style`,
],
[
{ selected: `selected-style`, option: `option-style` },
`option`,
`option-style`,
],
// Invalid object style cases
[
{ invalid: `invalid-style` },
`selected`,
`Invalid style object for option=${JSON.stringify({
style: { invalid: `invalid-style` },
})}`,
],
])(
`get_style returns correct style for different option and key combinations`,
async (style, key, expected) => {
console.error = vi.fn()

// @ts-expect-error test invalid option
const result = get_style({ style }, key)

if (expected.startsWith(`Invalid`) || expected.startsWith(`MultiSelect`)) {
expect(console.error).toHaveBeenCalledTimes(1)
expect(console.error).toHaveBeenCalledWith(expected)
} else {
expect(result).toBe(expected)
}
},
)

test.each([
// Invalid key cases
[`color: red;`, `invalid`, ``],
// Valid key cases
[`color: red;`, `selected`, `color: red;`],
[`color: red;`, `option`, `color: red;`],
[`color: red;`, null, `color: red;`],
// Object style cases
[
{ selected: `color: red;`, option: `color: blue;` },
`selected`,
`color: red;`,
],
[
{ selected: `color: red;`, option: `color: blue;` },
`option`,
`color: blue;`,
],
// Invalid object style cases
[{ invalid: `color: green;` }, `selected`, ``],
])(
`MultiSelect applies correct styles to <li> elements for different option and key combinations`,
async (style, key, expected_css) => {
const options = [{ label: `foo`, style }]

new MultiSelect({
target: document.body,
props: { options, selected: key === `selected` ? options : [] },
})

await tick()

if (key === `selected`) {
const selected_li = document.querySelector(`ul.selected > li`)
expect(selected_li.style.cssText).toBe(expected_css)
} else if (key === `option`) {
const option_li = document.querySelector(`ul.options > li`)
expect(option_li.style.cssText).toBe(expected_css)
}
},
)