AutoComplete updated to support REST service

This commit is contained in:
davideraccagni 2022-04-22 01:40:27 +02:00
commit 79828140a7
6 changed files with 313 additions and 281 deletions

View file

@ -5,6 +5,33 @@ components: ["AutoComplete", "AutoCompleteSkeleton"]
<script>
import { AutoComplete, AutoCompleteSkeleton, InlineNotification } from "carbon-components-svelte";
import Preview from "../../components/Preview.svelte";
let items = [];
fetch('https://restcountries.com/v3.1/all?fields=name,ccn3')
.then(res => {
if (!res.ok) {
throw new Error("Failed!");
}
return res.json();
})
.then(data => {
let _items = [];
Object.values(data).forEach(country => {
_items.push({ id: country.ccn3, text: country.name.common});
});
items = _items;
return items;
})
.catch(err => {
console.log(err);
});
function filteredItems(value) {
return value ? items.filter(country => country.text.startsWith(value)) : [];
}
</script>
`AutoComplete` is keyed for performance reasons.
@ -15,10 +42,7 @@ components: ["AutoComplete", "AutoCompleteSkeleton"]
### Default
<AutoComplete titleText="Contact" selectedId="0" items="{[{id: "0", text: "Slack"},
{id: "11", text: "Email1"},
{id: "12", text: "Email2"},
{id: "2", text: "Fax"}]}" />
<AutoComplete titleText="Contact" selectedId="0" shouldFilterItem="{filteredItems}" />
### Custom slot
@ -28,73 +52,41 @@ Override the default slot to customize the display of each item. Access the item
### Hidden label
<AutoComplete hideLabel titleText="Contact" selectedId="0" items="{[{id: "0", text: "Slack"},
{id: "1", text: "Email"},
{id: "2", text: "Fax"}]}" />
### Format item display text
Use the `itemToString` prop to format the display of individual items.
<AutoComplete itemToString={item => {
return item.text + ' (' + item.id +')'
}} titleText="Contact" selectedId="0" items="{[{id: "0", text: "Slack"},
{id: "1", text: "Email"},
{id: "2", text: "Fax"}]}" />
### Multiple dropdowns
<FileSource src="/framed/AutoComplete/MultipleAutoComplete" />
<AutoComplete hideLabel titleText="Contact" selectedId="0" shouldFilterItem="{filteredItems}" />
### Top direction
Set `direction` to `"top"` for the dropdown menu to appear above the input.
<AutoComplete direction="top" titleText="Contact" selectedId="0" items="{[{id: "0", text: "Slack"},
{id: "1", text: "Email"},
{id: "2", text: "Fax"}]}" />
<AutoComplete direction="top" titleText="Contact" selectedId="0" shouldFilterItem="{filteredItems}" />
### Light variant
<AutoComplete light titleText="Contact" selectedId="0" items="{[{id: "0", text: "Slack"},
{id: "1", text: "Email"},
{id: "2", text: "Fax"}]}" />
<AutoComplete light titleText="Contact" selectedId="0" shouldFilterItem="{filteredItems}" />
### Inline variant
<AutoComplete type="inline" titleText="Contact" selectedId="0" items="{[{id: "0", text: "Slack"},
{id: "1", text: "Email"},
{id: "2", text: "Fax"}]}" />
<AutoComplete type="inline" titleText="Contact" selectedId="0" shouldFilterItem="{filteredItems}" />
### Extra-large size
<AutoComplete size="xl" titleText="Contact" selectedId="0" items="{[{id: "0", text: "Slack"},
{id: "1", text: "Email"},
{id: "2", text: "Fax"}]}" />
<AutoComplete size="xl" titleText="Contact" selectedId="0" shouldFilterItem="{filteredItems}" />
### Small size
<AutoComplete size="sm" titleText="Contact" selectedId="0" items="{[{id: "0", text: "Slack"},
{id: "1", text: "Email"},
{id: "2", text: "Fax"}]}" />
<AutoComplete size="sm" titleText="Contact" selectedId="0" shouldFilterItem="{filteredItems}" />
### Invalid state
<AutoComplete invalid invalidText="Secondary contact method must be different from the primary contact" titleText="Contact" selectedId="0" items="{[{id: "0", text: "Slack"},
{id: "1", text: "Email"},
{id: "2", text: "Fax"}]}" />
<AutoComplete invalid invalidText="Secondary contact method must be different from the primary contact" titleText="Contact" selectedId="0" shouldFilterItem="{filteredItems}" />
### Warning state
<AutoComplete warn warnText="This contact method is not associated with your account" titleText="Contact" selectedId="0" items="{[{id: "0", text: "Slack"},
{id: "1", text: "Email"},
{id: "2", text: "Fax"}]}" />
<AutoComplete warn warnText="This contact method is not associated with your account" titleText="Contact" selectedId="0" shouldFilterItem="{filteredItems}" />
### Disabled state
<AutoComplete disabled titleText="Contact" selectedId="0" items="{[{id: "0", text: "Slack"},
{id: "1", text: "Email"},
{id: "2", text: "Fax"}]}" />
<AutoComplete disabled titleText="Contact" selectedId="0" shouldFilterItem="{filteredItems}" />
### Skeleton

View file

@ -18,6 +18,15 @@ items={[
{id: "2", text: "Fax"}
]} />
### `shouldFilterItem`
<ComboBox shouldFilterItem={(item, value) => item.text.startsWith(value)} titleText="Contact" placeholder="Select contact method"
items={[
{id: "0", text: "Slack"},
{id: "1", text: "Email"},
{id: "2", text: "Fax"}
]} />
### Custom slot
Override the default slot to customize the display of each item. Access the item and index through the `let:` directive.

View file

@ -1,15 +1,40 @@
<script>
import { AutoComplete } from "carbon-components-svelte";
let items = [];
fetch("https://restcountries.com/v3.1/all?fields=name,ccn3")
.then((res) => {
if (!res.ok) {
throw new Error("Failed!");
}
return res.json();
})
.then((data) => {
let _items = [];
Object.values(data).forEach((country) => {
_items.push({ id: country.ccn3, text: country.name.common });
});
items = _items;
return items;
})
.catch((err) => {
console.log(err);
});
function filteredItems(value) {
return value
? items.filter((country) => country.text.startsWith(value))
: [];
}
</script>
<AutoComplete
titleText="Contact"
selectedId="0"
items="{[
{ id: '0', text: 'Slack' },
{ id: '1', text: 'Email' },
{ id: '2', text: 'Fax' },
]}"
shouldFilterItem="{filteredItems}"
let:item
let:index
>

View file

@ -1,42 +0,0 @@
<script>
import { AutoComplete } from "carbon-components-svelte";
const items = [
{ id: "0", text: "Slack" },
{ id: "1", text: "Email" },
{ id: "2", text: "Fax" },
];
let auto_complete1_selectedId = "0";
let auto_complete2_selectedId = "1";
const formatSelected = (id) =>
items.find((item) => item.id === id)?.text ?? "N/A";
$: primary = formatSelected(auto_complete1_selectedId);
$: secondary = formatSelected(auto_complete2_selectedId);
</script>
<AutoComplete
titleText="Primary contact"
bind:selectedId="{auto_complete1_selectedId}"
items="{items}"
/>
<div>Primary: {primary}</div>
<AutoComplete
invalid="{auto_complete1_selectedId === auto_complete2_selectedId}"
invalidText="Secondary contact method must be different from the primary contact"
titleText="Secondary contact"
bind:selectedId="{auto_complete2_selectedId}"
items="{items}"
/>
<div>Secondary: {secondary}</div>
<style>
div {
margin: var(--cds-layout-01) 0 var(--cds-layout-03);
}
</style>

View file

@ -7,12 +7,6 @@
* @slot {{ item: AutoCompleteItem; index: number; }}
*/
/**
* Set the full list of items
* @type {AutoCompleteItem[]}
*/
export let items = [];
/**
* Override the display of a dropdown item
* @type {(item: AutoCompleteItem) => string}
@ -32,10 +26,10 @@
export let selectedItem = undefined;
/**
* Specify the type of dropdown
* @type {"default" | "inline"}
* Determine if an item should be filtered given the current combobox value
* @type {(value: string) => AutoCompleteItem[]}
*/
export let type = "default";
export let shouldFilterItem = () => [];
/**
* Specify the direction of the dropdown menu
@ -52,9 +46,6 @@
/** Set to `true` to open the dropdown */
export let open = false;
/** Set to `true` to use the inline variant */
export let inline = false;
/** Set to `true` to enable the light variant */
export let light = false;
@ -103,80 +94,100 @@
/** Specify the placeholder text */
export let placeholder = null;
import { createEventDispatcher } from "svelte";
/**
* Obtain a reference to the list HTML element
* @type {null | HTMLDivElement}
*/
export let listRef = null;
import { createEventDispatcher, afterUpdate, tick } from "svelte";
import Checkmark from "../icons/Checkmark.svelte";
import WarningFilled from "../icons/WarningFilled.svelte";
import WarningAltFilled from "../icons/WarningAltFilled.svelte";
import { ListBox, ListBoxMenu, ListBoxMenuItem } from "../ListBox";
import ListBox from "../ListBox/ListBox.svelte";
import ListBoxField from "../ListBox/ListBoxField.svelte";
import ListBoxMenu from "../ListBox/ListBoxMenu.svelte";
import ListBoxMenuIcon from "../ListBox/ListBoxMenuIcon.svelte";
import ListBoxMenuItem from "../ListBox/ListBoxMenuItem.svelte";
import ListBoxSelection from "../ListBox/ListBoxSelection.svelte";
const dispatch = createEventDispatcher();
let filteredItems = [];
let inputValue = value;
let prevSelectedId = null;
let highlightedIndex = -1;
let innerValue = undefined;
function change(dir) {
let index = highlightedIndex + dir;
if (index < 0) {
index = filteredItems.length - 1;
} else if (index >= filteredItems.length) {
index = 0;
}
highlightedIndex = index;
}
function onKeydown(event) {
let key = event.key;
if (["Enter", "ArrowDown", "ArrowUp"].includes(key)) {
event.preventDefault();
}
if (key === "Enter") {
open = !open;
if (
highlightedIndex > -1 &&
filteredItems[highlightedIndex].id !== selectedId
) {
selectedItem = filteredItems[highlightedIndex];
selectedId = selectedItem.id;
innerValue = selectedItem.text;
open = false;
}
} else if (key === "Backspace") {
selectedItem = undefined;
/**
* Clear the combo box programmatically
* @type {(options?: { focus?: boolean; }) => void}
*/
export function clear(options = {}) {
prevSelectedId = null;
highlightedIndex = -1;
highlightedId = undefined;
selectedId = undefined;
open = innerValue.length > 0 && filteredItems.length > 0;
} else if (key === "Tab") {
open = false;
ref.blur();
} else if (key === "ArrowDown") {
change(1);
} else if (key === "ArrowUp") {
change(-1);
} else if (key === "Escape") {
innerValue = "";
dispatch("clear");
selectedItem = undefined;
open = false;
inputValue = "";
if (options?.focus !== false) ref?.focus();
}
afterUpdate(() => {
if (open) {
ref.focus();
filteredItems = shouldFilterItem(value);
} else {
if (!open) open = filteredItems.length > 0;
highlightedIndex = -1;
filteredItems = [];
if (!selectedItem) {
selectedId = undefined;
inputValue = "";
highlightedIndex = -1;
highlightedId = undefined;
} else {
// programmatically set inputValue
inputValue = selectedItem.text;
}
}
});
$: if (selectedId !== undefined) {
if (prevSelectedId !== selectedId) {
prevSelectedId = selectedId;
if (filteredItems?.length === 1 && open) {
selectedId = filteredItems[0].id;
selectedItem = filteredItems[0];
highlightedIndex = -1;
highlightedId = undefined;
} else {
selectedItem = filteredItems.find((item) => item.id === selectedId);
}
dispatch("select", { selectedId, selectedItem });
}
$: filteredItems = items.filter(
(item) => innerValue?.length > 0 && item.text.startsWith(innerValue)
);
$: inline = type === "inline";
$: if (!open) {
highlightedIndex = -1;
} else {
prevSelectedId = selectedId;
selectedItem = undefined;
}
$: ariaLabel = $$props["aria-label"] || "Choose an item";
$: menuId = `menu-${id}`;
$: comboId = `combo-${id}`;
$: highlightedId = filteredItems[highlightedIndex]
? filteredItems[highlightedIndex].id
: 0;
$: filteredItems = shouldFilterItem(value);
$: value = inputValue;
</script>
<svelte:window
@ -187,88 +198,169 @@
}}"
/>
<div {...$$restProps}>
{#if titleText}
<div class:bx--list-box__wrapper="{true}">
{#if titleText && !hideLabel}
<label
for="{id}"
class:bx--label="{true}"
class:bx--label--disabled="{disabled}"
class:bx--visually-hidden="{hideLabel}"
>
{titleText}
</label>
{/if}
<ListBox
type="{type}"
size="{size}"
id="{id}"
name="{name}"
aria-label="{$$props['aria-label']}"
class="bx--dropdown {direction === 'top' && 'bx--list-box--up'} {invalid &&
'bx--dropdown--invalid'} {!invalid &&
warn &&
'bx--dropdown--warning'} {open && 'bx--dropdown--open'}
{size === 'sm' && 'bx--dropdown--sm'}
{size === 'xl' && 'bx--dropdown--xl'}
{inline && 'bx--dropdown--inline'}
{disabled && 'bx--dropdown--disabled'}
{light && 'bx--dropdown--light'}"
on:click="{({ target }) => {
if (disabled) return;
open = ref.contains(target) ? !open : false;
}}"
class="bx--combo-box {direction === 'top' &&
'bx--list-box--up'} {!invalid && warn && 'bx--combo-box--warning'}"
id="{comboId}"
aria-label="{ariaLabel}"
disabled="{disabled}"
open="{open}"
invalid="{invalid}"
invalidText="{invalidText}"
open="{open}"
light="{light}"
size="{size}"
warn="{warn}"
warnText="{warnText}"
>
<ListBoxField
role="button"
aria-expanded="{open}"
on:click="{async () => {
if (disabled) return;
open = true;
await tick();
ref.focus();
}}"
id="{id}"
disabled="{disabled}"
translateWithId="{translateWithId}"
>
<input
bind:this="{ref}"
tabindex="0"
autocomplete="off"
aria-autocomplete="list"
aria-expanded="{open}"
aria-activedescendant="{highlightedId}"
aria-labelledby="{comboId}"
aria-disabled="{disabled}"
aria-controls="{open ? menuId : undefined}"
aria-owns="{open ? menuId : undefined}"
disabled="{disabled}"
placeholder="{placeholder}"
id="{id}"
value="{inputValue}"
name="{name}"
{...$$restProps}
class:bx--text-input="{true}"
class:bx--text-input--light="{light}"
class:bx--text-input--empty="{inputValue === ''}"
on:input="{async ({ target }) => {
if (!open && target.value.length > 0) {
open = true;
}
inputValue = target.value;
if (!inputValue.length) {
clear();
open = true;
}
}}"
on:keydown
on:keydown|stopPropagation="{({ key }) => {
if (key === 'Enter') {
open = !open;
if (
highlightedIndex > -1 &&
filteredItems[highlightedIndex]?.id !== selectedId
) {
open = false;
if (filteredItems[highlightedIndex]) {
inputValue = filteredItems[highlightedIndex].text;
selectedItem = filteredItems[highlightedIndex];
selectedId = filteredItems[highlightedIndex].id;
}
} else {
open = false;
if (filteredItems[0]) {
inputValue = filteredItems[0].text;
selectedItem = filteredItems[0];
selectedId = filteredItems[0].id;
}
}
highlightedIndex = -1;
} else if (key === 'Tab') {
open = false;
} else if (key === 'ArrowDown') {
change(1);
} else if (key === 'ArrowUp') {
change(-1);
} else if (key === 'Escape') {
open = false;
}
}}"
on:keyup
on:focus
on:blur
on:blur="{({ relatedTarget }) => {
if (!open || !relatedTarget) return;
if (
relatedTarget &&
!['INPUT', 'SELECT', 'TEXTAREA'].includes(relatedTarget.tagName) &&
relatedTarget.getAttribute('role') !== 'button' &&
relatedTarget.getAttribute('role') !== 'searchbox'
) {
ref.focus();
}
}}"
/>
{#if invalid}
<WarningFilled class="bx--text-input__invalid-icon" />
<WarningFilled class="bx--list-box__invalid-icon" />
{/if}
{#if !invalid && warn}
<WarningAltFilled
class="bx--text-input__invalid-icon
bx--text-input__invalid-icon--warning"
class="bx--list-box__invalid-icon bx--list-box__invalid-icon--warning"
/>
{/if}
<input
bind:this="{ref}"
bind:value="{innerValue}"
type="text"
role="searchbox"
class="
auto-complete__input
{size === 'sm' && 'auto-complete__input--sm'}
{size === 'xl' && 'auto-complete__input--xl'}
"
autocomplete="false"
disabled="{disabled}"
id="{id}"
name="{name}"
placeholder="{placeholder}"
{#if inputValue}
<ListBoxSelection
on:clear
on:clear="{clear}"
translateWithId="{translateWithId}"
{...$$restProps}
on:change
on:focus
on:blur
on:input
on:keydown="{onKeydown}"
disabled="{disabled}"
open="{open}"
/>
{/if}
<ListBoxMenuIcon
on:click="{(e) => {
if (disabled) return;
e.stopPropagation();
open = !open;
}}"
translateWithId="{translateWithId}"
open="{open}"
/>
</ListBoxField>
{#if open}
<ListBoxMenu aria-labelledby="{id}" id="{id}">
<ListBoxMenu
aria-label="{ariaLabel}"
id="{id}"
on:scroll
bind:ref="{listRef}"
>
{#each filteredItems as item, i (item.id)}
<ListBoxMenuItem
id="{item.id}"
active="{selectedId === item.id}"
highlighted="{highlightedIndex === i || selectedId === item.id}"
highlighted="{highlightedIndex === i}"
on:click="{() => {
selectedItem = item;
selectedId = item.id;
innerValue = item.text;
ref.focus();
open = false;
if (filteredItems[i]) {
inputValue = filteredItems[i].text;
}
}}"
on:mouseenter="{() => {
highlightedIndex = i;
@ -277,12 +369,15 @@
<slot item="{item}" index="{i}">
{itemToString(item)}
</slot>
{#if selectedItem && selectedItem.id === item.id}
<Checkmark class="bx--list-box__menu-item__selected-icon" />
{/if}
</ListBoxMenuItem>
{/each}
</ListBoxMenu>
{/if}
</ListBox>
{#if !inline && !invalid && !warn && helperText}
{#if !invalid && helperText && !warn}
<div
class:bx--form__helper-text="{true}"
class:bx--form__helper-text--disabled="{disabled}"
@ -291,50 +386,3 @@
</div>
{/if}
</div>
<style>
.auto-complete__input {
font-size: var(--cds-body-short-01-font-size, 0.875rem);
font-weight: var(--cds-body-short-01-font-weight, 400);
line-height: var(--cds-body-short-01-line-height, 1.28572);
letter-spacing: var(--cds-body-short-01-letter-spacing, 0.16px);
outline: 2px solid transparent;
outline-offset: -2px;
width: 100%;
height: 2.5rem;
padding: 0 1rem;
border: none;
border-bottom-color: currentcolor;
border-bottom-style: none;
border-bottom-width: medium;
border-bottom: 1px solid var(--cds-ui-04, #8d8d8d);
background-color: var(--cds-field-01, #f4f4f4);
color: var(--cds-text-01, #161616);
transition: background-color 70ms cubic-bezier(0.2, 0, 0.38, 0.9),
outline 70ms cubic-bezier(0.2, 0, 0.38, 0.9);
}
.auto-complete__input:focus {
outline: 2px solid var(--cds-focus, #0f62fe);
outline-offset: -2px;
}
.auto-complete__input--sm {
height: 2rem;
}
.auto-complete__input--xl,
.auto-complete__input--lg {
height: 3rem;
}
.auto-complete__input:disabled {
outline: 2px solid transparent;
outline-offset: -2px;
border-bottom: 1px solid transparent;
background-color: var(--cds-field, #f4f4f4);
color: var(--cds-text-disabled, #c6c6c6);
cursor: not-allowed;
-webkit-text-fill-color: var(--cds-disabled-02, #c6c6c6);
}
</style>

View file

@ -11,13 +11,7 @@ export interface AutoCompleteItem {
}
export interface AutoCompleteProps
extends svelte.JSX.HTMLAttributes<HTMLElementTagNameMap["div"]> {
/**
* Set the full list of items
* @default []
*/
items?: AutoCompleteItem[];
extends svelte.JSX.HTMLAttributes<HTMLElementTagNameMap["input"]> {
/**
* Override the display of a dropdown item
* @default (item) => item.text || item.id
@ -37,10 +31,10 @@ export interface AutoCompleteProps
selectedItem?: AutoCompleteItem;
/**
* Specify the type of dropdown
* @default "default"
* Determine if an item should be filtered given the current combobox value
* @default () => []
*/
type?: "default" | "inline";
shouldFilterItem?: (value: string) => AutoCompleteItem[];
/**
* Specify the direction of the dropdown menu
@ -60,12 +54,6 @@ export interface AutoCompleteProps
*/
open?: boolean;
/**
* Set to `true` to use the inline variant
* @default false
*/
inline?: boolean;
/**
* Set to `true` to enable the light variant
* @default false
@ -149,6 +137,12 @@ export interface AutoCompleteProps
* @default null
*/
placeholder?: undefined;
/**
* Obtain a reference to the list HTML element
* @default null
*/
listRef?: null | HTMLDivElement;
}
export default class AutoComplete extends SvelteComponentTyped<
@ -158,11 +152,17 @@ export default class AutoComplete extends SvelteComponentTyped<
selectedId: AutoCompleteItemId;
selectedItem: AutoCompleteItem;
}>;
change: WindowEventMap["change"];
keydown: WindowEventMap["keydown"];
keyup: WindowEventMap["keyup"];
focus: WindowEventMap["focus"];
blur: WindowEventMap["blur"];
input: WindowEventMap["input"];
clear: CustomEvent<any>;
clear: WindowEventMap["clear"];
scroll: WindowEventMap["scroll"];
},
{ default: { item: AutoCompleteItem; index: number } }
> {}
> {
/**
* Clear the combo box programmatically
*/
clear: (options?: { focus?: boolean }) => void;
}