Skip to content

Commit 4f5b3c3

Browse files
committed
✨ Add List component
1 parent 7d7a6e2 commit 4f5b3c3

File tree

15 files changed

+851
-3
lines changed

15 files changed

+851
-3
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,7 @@ import { Accordion } from 'webcoreui/react'
205205
- [ConditionalWrapper](https://github.com/Frontendland/webcoreui/tree/main/src/components/ConditionalWrapper)
206206
- [Icon](https://github.com/Frontendland/webcoreui/tree/main/src/components/Icon)
207207
- [Input](https://github.com/Frontendland/webcoreui/tree/main/src/components/Input)
208+
- [List](https://github.com/Frontendland/webcoreui/tree/main/src/components/List)
208209
- [Menu](https://github.com/Frontendland/webcoreui/tree/main/src/components/Menu)
209210
- [Modal](https://github.com/Frontendland/webcoreui/tree/main/src/components/Modal)
210211
- [Popover](https://github.com/Frontendland/webcoreui/tree/main/src/components/Popover)

scripts/buildTypes.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ const buildTypes = type => {
2626
'Button',
2727
'Checkbox',
2828
'Input',
29+
'List',
2930
'Radio',
3031
'Switch',
3132
'Slider',

src/components/Icon/map.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import Close from '../../icons/close.svg?raw'
66
import Github from '../../icons/github.svg?raw'
77
import Info from '../../icons/info.svg?raw'
88
import Moon from '../../icons/moon.svg?raw'
9+
import Search from '../../icons/search.svg?raw'
910
import Sun from '../../icons/sun.svg?raw'
1011
import Warning from '../../icons/warning.svg?raw'
1112

@@ -18,6 +19,7 @@ const iconMap = {
1819
'github': Github,
1920
'info': Info,
2021
'moon': Moon,
22+
'search': Search,
2123
'sun': Sun,
2224
'warning': Warning
2325
}

src/components/List/List.astro

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
---
2+
import type { ListProps } from './list'
3+
import ConditionalWrapper from '../ConditionalWrapper/ConditionalWrapper.astro'
4+
import Input from '../Input/Input.astro'
5+
6+
import searchIcon from '../../icons/search.svg?raw'
7+
import styles from './list.module.scss'
8+
9+
interface Props extends ListProps {}
10+
11+
const {
12+
showSearchBar,
13+
showSearchBarIcon,
14+
searchBarPlaceholder,
15+
noResultsLabel = 'No results.',
16+
maxHeight,
17+
id,
18+
className,
19+
wrapperClassName,
20+
itemGroups
21+
} = Astro.props
22+
23+
const classes = [
24+
styles.list,
25+
!showSearchBar && styles.container,
26+
className
27+
]
28+
29+
const wrapperClasses = [
30+
styles.container,
31+
wrapperClassName
32+
]
33+
---
34+
35+
<ConditionalWrapper condition={!!showSearchBar}>
36+
<div slot="wrapper" class:list={wrapperClasses} data-id="w-list-wrapper">
37+
<Input
38+
type="search"
39+
placeholder={searchBarPlaceholder}
40+
data-id="w-list-search"
41+
>
42+
<Fragment
43+
set:html={searchIcon}
44+
slot={showSearchBarIcon ? 'default' : null}
45+
/>
46+
</Input>
47+
children
48+
</div>
49+
<ul class:list={classes} id={id} data-id="w-list" style={maxHeight && `max-height: ${maxHeight}`}>
50+
{itemGroups.map((group: ListProps['itemGroups'][0]) => (
51+
<Fragment>
52+
{group.title && (
53+
<li class={styles.title} data-id="w-list-title">
54+
{group.title}
55+
</li>
56+
)}
57+
{group.items.map(item => (
58+
<li
59+
tabindex={item.href || item.disabled ? undefined : 0}
60+
data-value={item.value}
61+
data-name={item.name}
62+
data-disabled={item.disabled}
63+
data-selected={item.selected}
64+
>
65+
<ConditionalWrapper condition={!!item.href}>
66+
<a
67+
slot="wrapper"
68+
href={item.href}
69+
target={item.target}
70+
>
71+
children
72+
</a>
73+
74+
<ConditionalWrapper condition={!!(item.icon && item.subText)}>
75+
<div slot="wrapper">children</div>
76+
{item.icon && <Fragment set:html={item.icon} />}
77+
{item.name}
78+
</ConditionalWrapper>
79+
{item.subText && <span>{item.subText}</span>}
80+
</ConditionalWrapper>
81+
</li>
82+
))}
83+
</Fragment>
84+
))}
85+
{showSearchBar && (
86+
<li data-id="w-no-results" data-hidden>{noResultsLabel}</li>
87+
)}
88+
</ul>
89+
</ConditionalWrapper>
90+
91+
<script>
92+
import { dispatch } from '../../utils/event'
93+
94+
const lists = document.querySelectorAll('[data-id="w-list"]')
95+
const searchInputs = document.querySelectorAll('[data-id="w-list-search"]')
96+
97+
const handleClick = (list: Element, items: Element[], event: Event) => {
98+
const target = event.target as HTMLElement
99+
100+
if (target.dataset.value) {
101+
dispatch('listOnSelect', {
102+
value: target.dataset.value,
103+
name: target.dataset.name,
104+
list,
105+
})
106+
107+
items.forEach(item => item.removeAttribute('data-selected'))
108+
target.dataset.selected = 'true'
109+
}
110+
}
111+
112+
Array.from(lists).forEach(list => {
113+
const items = Array.from(list.children)
114+
115+
list.addEventListener('click', event => handleClick(list, items, event))
116+
list.addEventListener('keyup', event => {
117+
if ((event as KeyboardEvent).key === 'Enter') {
118+
handleClick(list, items, event)
119+
}
120+
})
121+
})
122+
123+
Array.from(searchInputs).forEach(element => {
124+
element.addEventListener('input', event => {
125+
const target = event.target as HTMLInputElement
126+
const ul = target.closest('[data-id="w-list-wrapper"]')
127+
?.querySelector('ul') as HTMLUListElement
128+
129+
const noResults = ul.querySelector('[data-id="w-no-results"]')
130+
const items = Array.from(ul.children)
131+
const value = target.value.toLowerCase()
132+
133+
items.forEach(item => {
134+
const li = item as HTMLLIElement
135+
const hideItem = (!li.dataset.value?.toLowerCase().includes(value)
136+
&& !li.innerText.toLowerCase().includes(value))
137+
|| li.dataset.id === 'w-list-title'
138+
|| li.dataset.id === 'w-no-results'
139+
140+
if (hideItem) {
141+
li.dataset.hidden = 'true'
142+
} else if (li.dataset.id !== 'w-no-results') {
143+
li.removeAttribute('data-hidden')
144+
}
145+
})
146+
147+
const numberOfResults = items.filter(item => {
148+
const li = item as HTMLLIElement
149+
return li.dataset.name && !li.dataset.hidden
150+
}).length
151+
152+
if (!numberOfResults) {
153+
noResults?.removeAttribute('data-hidden')
154+
}
155+
156+
if (!value) {
157+
items.forEach(item => {
158+
const li = item as HTMLLIElement
159+
160+
li.dataset.id === 'w-no-results'
161+
? li.dataset.hidden = 'true'
162+
: li.removeAttribute('data-hidden')
163+
})
164+
}
165+
})
166+
})
167+
</script>

src/components/List/List.svelte

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
<script lang="ts">
2+
import type { SvelteListProps } from './list'
3+
import ConditionalWrapper from '../ConditionalWrapper/ConditionalWrapper.svelte'
4+
import Input from '../Input/Input.svelte'
5+
6+
import searchIcon from '../../icons/search.svg?raw'
7+
import styles from './list.module.scss'
8+
import { classNames } from '../../utils/classNames'
9+
10+
export let showSearchBar: SvelteListProps['showSearchBar'] = false
11+
export let showSearchBarIcon: SvelteListProps['showSearchBarIcon'] = false
12+
export let searchBarPlaceholder: SvelteListProps['searchBarPlaceholder'] = ''
13+
export let noResultsLabel: SvelteListProps['noResultsLabel'] = 'No results.'
14+
export let maxHeight: SvelteListProps['maxHeight'] = ''
15+
export let id: SvelteListProps['id'] = ''
16+
export let className: SvelteListProps['className'] = ''
17+
export let wrapperClassName: SvelteListProps['wrapperClassName'] = ''
18+
export let itemGroups: SvelteListProps['itemGroups'] = []
19+
export let onSelect: SvelteListProps['onSelect'] = () => {}
20+
21+
let searchValue = ''
22+
let numberOfResults = 1
23+
24+
const classes = classNames([
25+
styles.list,
26+
!showSearchBar && styles.container,
27+
className
28+
])
29+
30+
const wrapperClasses = classNames([
31+
styles.container,
32+
wrapperClassName
33+
])
34+
35+
const search = (event: KeyboardEvent) => {
36+
searchValue = (event.target as HTMLInputElement).value
37+
38+
numberOfResults = itemGroups
39+
.map(group => group.items)
40+
.flat()
41+
.filter(item => {
42+
return item.value?.toLowerCase().includes(searchValue)
43+
|| item.subText?.toLowerCase().includes(searchValue)
44+
|| item.name.toLowerCase().includes(searchValue)
45+
}).length
46+
}
47+
48+
const select = (event: MouseEvent | KeyboardEvent) => {
49+
const li = event.target as HTMLLIElement
50+
51+
itemGroups = itemGroups.map(group => {
52+
group.items = group.items.map(item => {
53+
item.selected = li.dataset.name === item.name
54+
55+
return item
56+
})
57+
58+
return group
59+
})
60+
61+
onSelect?.({
62+
...li.dataset,
63+
list: li.parentElement
64+
})
65+
}
66+
67+
const selectByKey = (event: KeyboardEvent) => {
68+
if (event.key === 'Enter') {
69+
select(event)
70+
}
71+
}
72+
</script>
73+
74+
<ConditionalWrapper
75+
condition={!!showSearchBar}
76+
class={wrapperClasses}
77+
>
78+
{#if showSearchBar}
79+
<Input
80+
type="search"
81+
placeholder={searchBarPlaceholder}
82+
onInput={search}
83+
>
84+
{#if showSearchBarIcon}
85+
{@html searchIcon}
86+
{/if}
87+
</Input>
88+
{/if}
89+
<ul
90+
class={classes}
91+
id={id || null}
92+
style={maxHeight ? `max-height: ${maxHeight}` : null}
93+
>
94+
{#each itemGroups as group}
95+
{#if group.title}
96+
<li class={styles.title}
97+
data-hidden={searchValue ? true : null}
98+
>
99+
{group.title}
100+
</li>
101+
{/if}
102+
{#each group.items as item}
103+
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
104+
<li
105+
tabIndex={item.href || item.disabled ? undefined : 0}
106+
data-value={item.value}
107+
data-name={item.name}
108+
data-disabled={item.disabled}
109+
data-selected={item.selected ? true : undefined}
110+
data-hidden={(
111+
!item.value?.toLowerCase().includes(searchValue)
112+
&& !item.subText?.toLowerCase().includes(searchValue)
113+
&& !item.name.toLowerCase().includes(searchValue)
114+
) ? true : null}
115+
on:click={item.disabled ? null : select}
116+
on:keyup={item.disabled ? null : selectByKey}
117+
>
118+
<ConditionalWrapper
119+
condition={!!item.href}
120+
element="a"
121+
href={item.href}
122+
target={item.target}
123+
>
124+
<ConditionalWrapper
125+
condition={!!(item.icon && item.subText)}
126+
>
127+
{#if item.icon}
128+
{@html item.icon}
129+
{/if}
130+
{item.name}
131+
</ConditionalWrapper>
132+
{#if item.subText}
133+
<span>{item.subText}</span>
134+
{/if}
135+
</ConditionalWrapper>
136+
</li>
137+
{/each}
138+
{/each}
139+
140+
{#if showSearchBar && !numberOfResults}
141+
<li data-id="w-no-results">{noResultsLabel}</li>
142+
{/if}
143+
</ul>
144+
</ConditionalWrapper>

0 commit comments

Comments
 (0)