Skip to content

Commit de607f1

Browse files
cristineculaionut
andauthored
feat: cosmoz-autocomplete-excluding (#210)
* feat: cosmoz-autocomplete-excluding New component that allows users to include or exclude options. Fixes #209 * feat: custom itemRenderer for x and forward all options to cosmoz-autocomplete * fix: remove the unused imports and commented line * feat: custom chipRenderer to show red chips * feat: implement component helper pattern autocomplete(props) * feat(excluding): clear item from chip * fix: set css custom properties for props data-excluded * fix: isolate css for autocomplete excluding, update autocomplete excluding story * fix: set names inside story for better reusability of custom properties * fix: set correct argTypes for excluded background color in story, set clear icon stroke bg * fix: removed fallback values from excluded storybook * fix: changed name for custom properties and set story default values * fix: set one excluded default item in story * fix: cleanup * test: add e2e test for excluding --------- Co-authored-by: Husoschi Ionut-Catalin <ionuthusoschi@gmail.com>
1 parent 6f29f68 commit de607f1

18 files changed

+484
-26
lines changed

src/autocomplete/autocomplete.ts

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,25 @@
1+
import {
2+
Placement,
3+
defaultMiddleware,
4+
size,
5+
useFloating,
6+
} from '@neovici/cosmoz-dropdown/use-floating';
17
import '@neovici/cosmoz-input';
2-
import './skeleton-span';
38
import { useHost } from '@neovici/cosmoz-utils/hooks/use-host';
49
import { useImperativeApi } from '@neovici/cosmoz-utils/hooks/use-imperative-api';
10+
import { useEffect } from '@pionjs/pion';
511
import { html } from 'lit-html';
612
import { live } from 'lit-html/directives/live.js';
13+
import { ref } from 'lit-html/directives/ref.js';
714
import { until } from 'lit-html/directives/until.js';
815
import { when } from 'lit-html/directives/when.js';
916
import { listbox } from '../listbox';
10-
import { selection } from './selection';
17+
import { ItemRenderer } from '../listbox/item-renderer';
18+
import { ChipRenderer, selection } from './selection';
19+
import './skeleton-span';
1120
import style from './styles.css';
1221
import { Props as Base, RProps, useAutocomplete } from './use-autocomplete';
1322
import { useOverflow } from './use-overflow';
14-
import { ref } from 'lit-html/directives/ref.js';
15-
import {
16-
useFloating,
17-
Placement,
18-
defaultMiddleware,
19-
size,
20-
} from '@neovici/cosmoz-dropdown/use-floating';
21-
import { useEffect } from '@pionjs/pion';
2223

2324
export interface Props<I> extends Base<I> {
2425
invalid?: boolean;
@@ -34,6 +35,8 @@ export interface Props<I> extends Base<I> {
3435
defaultIndex?: number;
3536
externalSearch?: boolean;
3637
placement?: Placement;
38+
itemRenderer?: ItemRenderer<I>;
39+
chipRenderer?: ChipRenderer<I>;
3740
}
3841

3942
type AProps<I> = Omit<Props<I>, keyof RProps<I>> &
@@ -94,6 +97,7 @@ const autocomplete = <I>(props: AProps<I>) => {
9497
source$,
9598
placement,
9699
loading,
100+
chipRenderer,
97101
} = props,
98102
host = useHost(),
99103
isOne = limit == 1, // eslint-disable-line eqeqeq
@@ -167,6 +171,7 @@ const autocomplete = <I>(props: AProps<I>) => {
167171
onDeselect,
168172
textual,
169173
disabled,
174+
chipRenderer,
170175
})}
171176
</cosmoz-input>
172177

src/autocomplete/chip.css.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,25 @@ export default css`
44
:host {
55
border-radius: var(--cosmoz-autocomplete-chip-border-radius, 500px);
66
background: var(--cosmoz-autocomplete-chip-bg-color, #cbcfdb);
7-
margin: 0px 0 2px 0;
7+
margin-bottom: 2px;
88
display: flex;
9-
flex-direction: row;
109
align-items: center;
1110
flex: 0.0001 1 fit-content;
1211
max-width: 18ch;
1312
min-width: 40px;
14-
padding: 0 4px 0 8px;
13+
padding-inline: 8px;
1514
gap: 4px;
1615
cursor: pointer;
1716
transform: translateY(var(--cosmoz-autocomplete-chip-translate-y, 0));
1817
}
18+
1919
.content {
2020
color: var(--cosmoz-autocomplete-chip-color, #424242);
21-
font-family: var(--cosmoz-autocomplete-chip-text-font-family, 'Inter', sans-serif);
21+
font-family: var(
22+
--cosmoz-autocomplete-chip-text-font-family,
23+
'Inter',
24+
sans-serif
25+
);
2226
font-size: var(--cosmoz-autocomplete-chip-text-font-size, 12px);
2327
font-weight: var(--cosmoz-autocomplete-chip-text-font-weight, 400);
2428
line-height: var(--cosmoz-autocomplete-chip-text-line-height, 22px);
@@ -27,20 +31,24 @@ export default css`
2731
white-space: nowrap;
2832
flex: auto;
2933
min-width: 16px;
34+
text-align: center;
3035
}
36+
3137
.clear {
3238
background-color: var(--cosmoz-autocomplete-chip-clear-bg-color, #81899b);
3339
border-radius: 50%;
3440
cursor: pointer;
3541
width: 16px;
3642
height: 16px;
43+
margin-right: -4px;
3744
stroke: var(
3845
--cosmoz-autocomplete-chip-clear-stroke,
3946
var(--cosmoz-autocomplete-chip-bg-color, #cbcfdb)
4047
);
4148
display: var(--cosmoz-autocomplete-chip-clear-display, block);
4249
flex: none;
4350
}
51+
4452
.clear:hover {
4553
filter: brightness(90%);
4654
}

src/autocomplete/chip.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,20 +57,21 @@ customElements.define(
5757
}),
5858
);
5959

60-
interface ChipProps extends Props {
60+
export interface ChipProps<I> extends Props {
61+
item: I | null;
6162
slot?: string;
6263
className?: string;
6364
content: unknown;
6465
hidden?: boolean;
6566
}
66-
export const chip = ({
67+
export const chip = <I>({
6768
content,
6869
onClear,
6970
disabled,
7071
hidden,
7172
className = 'chip',
7273
slot,
73-
}: ChipProps) =>
74+
}: ChipProps<I>) =>
7475
html`<cosmoz-autocomplete-chip
7576
class=${ifDefined(className)}
7677
slot=${ifDefined(slot)}

src/autocomplete/index.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,7 @@ const Standalone = <I>(host: HTMLElement & Props<I>) => {
99
useEffect(() => {
1010
if (host.onChange == null) return;
1111
// eslint-disable-next-line no-console
12-
console.warn(
13-
'onChange is deprecated; use value-changed and lift instead',
14-
);
12+
console.warn('onChange is deprecated; use value-changed and lift instead');
1513
}, []);
1614

1715
return Autocomplete({

src/autocomplete/selection.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,19 @@
11
import { html } from 'lit-html';
2-
import { chip } from './chip';
2+
import { chip as defaultChipRenderer } from './chip';
33
import type { RProps } from './use-autocomplete';
44

55
type Deselect<I> = RProps<I>['onDeselect'];
66

7+
export type ChipRenderer<I> = typeof defaultChipRenderer<I>;
8+
79
interface Props<I> {
810
value: I[];
911
min?: number;
1012
isOne: boolean;
1113
onDeselect: Deselect<I>;
1214
textual: (i: I) => string;
1315
disabled?: boolean;
16+
chipRenderer?: ChipRenderer<I>;
1417
}
1518

1619
export const selection = <I>({
@@ -19,16 +22,19 @@ export const selection = <I>({
1922
onDeselect,
2023
textual,
2124
disabled,
25+
chipRenderer = defaultChipRenderer,
2226
}: Props<I>) => [
2327
...values.filter(Boolean).map((value) =>
24-
chip({
28+
chipRenderer({
29+
item: value,
2530
content: textual(value),
2631
onClear: values.length > min && (() => onDeselect(value)),
2732
disabled,
2833
slot: 'control',
2934
}),
3035
),
31-
chip({
36+
chipRenderer({
37+
item: null,
3238
content: html`<span></span>`,
3339
className: 'badge',
3440
disabled: true,

src/excluding/index.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import {
2+
component,
3+
html,
4+
useCallback,
5+
useMemo,
6+
useProperty,
7+
} from '@pionjs/pion';
8+
import { nothing } from 'lit-html';
9+
import { ifDefined } from 'lit-html/directives/if-defined.js';
10+
import '../autocomplete';
11+
import {
12+
Autocomplete,
13+
observedAttributes,
14+
Props,
15+
style,
16+
} from '../autocomplete/autocomplete';
17+
import { ChipProps } from '../autocomplete/chip';
18+
import { ItemRendererOpts } from '../listbox/item-renderer';
19+
import { WrappedItem } from './types';
20+
import { useExcludingSelection } from './use-excluding-selection';
21+
import { unwrap } from './utils';
22+
import excludingStyle from './style.css';
23+
24+
const isItemExcluded = <I>(value: WrappedItem<I>[] | undefined, item: I) =>
25+
value?.some((v) => v.item === item && v.excluded);
26+
27+
const excludedState = <I>(
28+
value: WrappedItem<I>[] | undefined,
29+
item: I | null,
30+
) => (item && isItemExcluded(value, item) ? 'excluded' : nothing);
31+
32+
const mkItemRenderer =
33+
<I>(value?: WrappedItem<I>[]) =>
34+
(
35+
item: I,
36+
i: number,
37+
{ highlight, select, textual, isSelected }: ItemRendererOpts<I>,
38+
) => {
39+
const rendered = textual(item);
40+
41+
return html`<div
42+
class="item"
43+
role="option"
44+
part="option ${excludedState(value, item)}"
45+
?aria-selected=${isSelected(item)}
46+
data-index=${i}
47+
@mouseenter=${() => highlight(i)}
48+
@click=${() => select(item)}
49+
@mousedown=${(e: Event) => e.preventDefault()}
50+
>
51+
${rendered}
52+
</div>
53+
<div class="sizer" virtualizer-sizer>${rendered}</div>`;
54+
};
55+
56+
const mkChipRenderer =
57+
<I>(value: WrappedItem<I>[] | undefined, onClear: (item: I | null) => void) =>
58+
({
59+
item,
60+
content,
61+
disabled,
62+
hidden,
63+
className = 'chip',
64+
slot,
65+
}: ChipProps<I>) =>
66+
html`<cosmoz-autocomplete-chip
67+
class=${ifDefined(className)}
68+
slot=${ifDefined(slot)}
69+
part="chip"
70+
exportparts="chip-text, chip-clear"
71+
data-state=${excludedState(value, item)}
72+
?disabled=${disabled}
73+
?hidden=${hidden}
74+
.onClear=${() => onClear(item)}
75+
title=${ifDefined(typeof content === 'string' ? content : undefined)}
76+
>
77+
${content}
78+
</cosmoz-autocomplete-chip>`;
79+
80+
const AutocompleteExcluding = <I>(props: Props<I>) => {
81+
const { value, setValue, setExcludingValue } =
82+
useExcludingSelection<I>('value');
83+
const [text, setText] = useProperty<string>('text');
84+
85+
const onClear = useCallback(
86+
(item: I | null) =>
87+
setValue((values) => values?.filter((i) => i.item !== item)),
88+
[],
89+
);
90+
91+
return Autocomplete({
92+
...props,
93+
value: useMemo(() => value?.map(unwrap), [value]),
94+
onChange: useCallback((value: I[]) => {
95+
setExcludingValue(value);
96+
}, []),
97+
text,
98+
onText: useCallback((text: string) => {
99+
setText(text);
100+
}, []),
101+
itemRenderer: useMemo(() => mkItemRenderer(value), [value]),
102+
chipRenderer: useMemo(
103+
() => mkChipRenderer(value, onClear),
104+
[value, onClear],
105+
),
106+
});
107+
};
108+
109+
customElements.define(
110+
'cosmoz-autocomplete-excluding',
111+
component(AutocompleteExcluding, {
112+
observedAttributes,
113+
styleSheets: [style, excludingStyle],
114+
}),
115+
);

src/excluding/style.css.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { css } from '@pionjs/pion';
2+
3+
const clearSVG =
4+
/* eslint-disable quotes */
5+
"data:image/svg+xml,%3Csvg width='11' height='11' viewBox='0 0 11 11' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath " +
6+
"d='M2.5 2.5L8.5 8.5M8.5 2.5L2.5 8.5' stroke='white' stroke-width='1.5' stroke-linecap='round'/%3E%3C/svg%3E";
7+
/* eslint-enable quotes */
8+
9+
const excludingStyle = css`
10+
.chip[data-state='excluded'] {
11+
background: var(--cosmoz-autocomplete-excluded-bg-color, rgb(244, 67, 54));
12+
}
13+
14+
cosmoz-autocomplete-chip[data-state='excluded']::part(content) {
15+
color: var(--cosmoz-autocomplete-excluded-chip-color, #fff);
16+
}
17+
18+
cosmoz-autocomplete-chip[data-state='excluded']::part(clear) {
19+
background-color: var(
20+
--cosmoz-autocomplete-excluded-chip-clear-bg-color,
21+
#fff
22+
);
23+
stroke: var(
24+
--cosmoz-autocomplete-excluded-chip-clear-stroke,
25+
var(--cosmoz-autocomplete-excluded-bg-color, rgb(244, 67, 54))
26+
);
27+
}
28+
29+
cosmoz-listbox::part(excluded)::before {
30+
border-color: var(
31+
--cosmoz-autocomplete-excluded-bg-color,
32+
rgb(244, 67, 54)
33+
);
34+
/* prettier-ignore */
35+
background: url("${clearSVG}") var(--cosmoz-autocomplete-excluded-bg-color, rgb(244, 67, 54)) no-repeat 50%;
36+
}
37+
38+
cosmoz-listbox::part(excluded):hover {
39+
background: var(
40+
--cosmoz-listbox-excluded-active-color,
41+
rgba(244, 67, 54, 0.1)
42+
);
43+
}
44+
`;
45+
46+
export default excludingStyle;

src/excluding/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export interface WrappedItem<I> {
2+
item: I;
3+
excluded: boolean;
4+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { invoke } from '@neovici/cosmoz-utils/function';
2+
import { StateUpdater, useCallback, useProperty } from '@pionjs/pion';
3+
import '../autocomplete';
4+
import { WrappedItem } from './types';
5+
import { unwrap, wrap } from './utils';
6+
7+
export const useExcludingSelection = <I>(property: string) => {
8+
const [value, setValue] = useProperty<WrappedItem<I>[]>(property);
9+
10+
const setExcludingValue: StateUpdater<I[] | undefined> = useCallback(
11+
(next) =>
12+
setValue((prev) => {
13+
const _next = invoke(next, prev?.map(unwrap));
14+
if (!_next) return undefined;
15+
if (!prev) return _next.map(wrap);
16+
17+
const results = prev.reduce((nextState, item) => {
18+
if (_next.includes(item.item)) return [...nextState, item];
19+
else if (item.excluded) return nextState;
20+
return [...nextState, { ...item, excluded: true }];
21+
}, [] as WrappedItem<I>[]);
22+
23+
const newItems = _next
24+
.filter((i) => !prev.some((p) => p.item === i))
25+
.map(wrap);
26+
27+
return [...results, ...newItems];
28+
}),
29+
[],
30+
);
31+
32+
return {value, setExcludingValue, setValue} as const;
33+
};

0 commit comments

Comments
 (0)