Refactor UI Shell global search component (#417)

* feat(ui-shell): refactor UI Shell search component

* fix(ui-shell): dispatched event is "select", not "search"

* test(header-search): validate HeaderSearch types

* fix(header-search): selecting a result should reset the search

* feat(header-search): deefault selectedResultIndex should be 0

Reset selectedResultIndex, value after dispatching.

* docs(header-search): demo basic filtering

* chore: rebuild types, docs

* feat(header-search): pass index as a slot prop
This commit is contained in:
Eric Liu 2020-11-26 15:42:15 -08:00 committed by GitHub
commit 30cfc842d5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 737 additions and 5 deletions

View file

@ -1,6 +1,6 @@
# Component Index
> 154 components exported from carbon-components-svelte@0.23.2.
> 155 components exported from carbon-components-svelte@0.23.2.
## Components
@ -57,6 +57,7 @@
- [`HeaderPanelDivider`](#headerpaneldivider)
- [`HeaderPanelLink`](#headerpanellink)
- [`HeaderPanelLinks`](#headerpanellinks)
- [`HeaderSearch`](#headersearch)
- [`HeaderUtilities`](#headerutilities)
- [`Icon`](#icon)
- [`IconSkeleton`](#iconskeleton)
@ -1609,6 +1610,48 @@ None.
None.
## `HeaderSearch`
### Types
```ts
export interface HeaderSearchResult {
href: string;
text: string;
description?: string;
}
```
### Props
| Prop name | Kind | Reactive | Type | Default value | Description |
| :------------------ | :--------------- | :------- | :---------------------------------------- | ------------------ | -------------------------------------------------- |
| selectedResultIndex | <code>let</code> | Yes | <code>number</code> | <code>0</code> | Specify the selected result index |
| ref | <code>let</code> | Yes | <code>null &#124; HTMLInputElement</code> | <code>null</code> | Obtain a reference to the input HTML element |
| active | <code>let</code> | Yes | <code>boolean</code> | <code>false</code> | Set to `true` to activate and focus the search bar |
| value | <code>let</code> | Yes | <code>string</code> | <code>""</code> | Specify the search input value |
| results | <code>let</code> | No | <code>HeaderSearchResult[]</code> | <code>[]</code> | Render a list of search results |
### Slots
| Slot name | Default | Props | Fallback |
| :-------- | :------ | :---------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------ |
| -- | Yes | <code>{ result: HeaderSearchResult; index: number } </code> | <code>{result.text}<br /> {#if result.description}&lt;span&gt; {result.description}&lt;/span&gt;{/if}</code> |
### Events
| Event name | Type | Detail |
| :--------- | :--------- | :---------------------------------------------------------------------------------------------- |
| active | dispatched | <code>any</code> |
| inactive | dispatched | <code>any</code> |
| clear | dispatched | <code>any</code> |
| select | dispatched | <code>{ value: string; selectedResultIndex: number; selectedResult: HeaderSearchResult }</code> |
| change | forwarded | -- |
| input | forwarded | -- |
| focus | forwarded | -- |
| blur | forwarded | -- |
| keydown | forwarded | -- |
## `HeaderUtilities`
### Props

View file

@ -1,5 +1,5 @@
{
"total": 154,
"total": 155,
"components": [
{
"moduleName": "SkeletonText",
@ -10258,6 +10258,93 @@
"typedefs": [],
"rest_props": { "type": "Element", "name": "button" }
},
{
"moduleName": "HeaderSearch",
"filePath": "/src/UIShell/HeaderSearch.svelte",
"props": [
{
"name": "value",
"kind": "let",
"description": "Specify the search input value",
"type": "string",
"value": "\"\"",
"isFunction": false,
"constant": false,
"reactive": true
},
{
"name": "active",
"kind": "let",
"description": "Set to `true` to activate and focus the search bar",
"type": "boolean",
"value": "false",
"isFunction": false,
"constant": false,
"reactive": true
},
{
"name": "ref",
"kind": "let",
"description": "Obtain a reference to the input HTML element",
"type": "null | HTMLInputElement",
"value": "null",
"isFunction": false,
"constant": false,
"reactive": true
},
{
"name": "results",
"kind": "let",
"description": "Render a list of search results",
"type": "HeaderSearchResult[]",
"value": "[]",
"isFunction": false,
"constant": false,
"reactive": false
},
{
"name": "selectedResultIndex",
"kind": "let",
"description": "Specify the selected result index",
"type": "number",
"value": "0",
"isFunction": false,
"constant": false,
"reactive": true
}
],
"slots": [
{
"name": "__default__",
"default": true,
"fallback": "{result.text}\n {#if result.description}<span> {result.description}</span>{/if}",
"slot_props": "{ result: HeaderSearchResult; index: number }"
}
],
"events": [
{ "type": "dispatched", "name": "active", "detail": "any" },
{ "type": "dispatched", "name": "inactive", "detail": "any" },
{ "type": "dispatched", "name": "clear", "detail": "any" },
{
"type": "dispatched",
"name": "select",
"detail": "{ value: string; selectedResultIndex: number; selectedResult: HeaderSearchResult }"
},
{ "type": "forwarded", "name": "change", "element": "input" },
{ "type": "forwarded", "name": "input", "element": "input" },
{ "type": "forwarded", "name": "focus", "element": "input" },
{ "type": "forwarded", "name": "blur", "element": "input" },
{ "type": "forwarded", "name": "keydown", "element": "input" }
],
"typedefs": [
{
"type": "{ href: string; text: string; description?: string; }",
"name": "HeaderSearchResult",
"ts": "interface HeaderSearchResult { href: string; text: string; description?: string; }"
}
],
"rest_props": { "type": "Element", "name": "input" }
},
{
"moduleName": "UnorderedList",
"filePath": "/src/UnorderedList/UnorderedList.svelte",

View file

@ -4,7 +4,7 @@
components: ["Header",
"HeaderAction",
"HeaderActionLink",
"HeaderActionSearch",
"HeaderSearch",
"HeaderNav",
"HeaderNavItem",
"HeaderNavMenu",
@ -34,6 +34,10 @@ components: ["Header",
<FileSource src="/framed/UIShell/HeaderSwitcher" />
### Header with global search
<FileSource src="/framed/UIShell/HeaderSearch" />
### Header with utilities
<FileSource src="/framed/UIShell/HeaderUtilities" />

View file

@ -0,0 +1,130 @@
<script>
import {
Header,
HeaderUtilities,
HeaderAction,
HeaderSearch,
HeaderPanelLinks,
HeaderPanelDivider,
HeaderPanelLink,
SkipToContent,
Content,
Grid,
Row,
Column,
Button,
} from "carbon-components-svelte";
const data = [
{
href: "/",
text: "Kubernetes Service",
description:
"Deploy secure, highly available apps in a native Kubernetes experience. IBM Cloud Kubernetes Service creates a cluster of compute hosts and deploys highly available containers.",
},
{
href: "/",
text: "Red Hat OpenShift on IBM Cloud",
description:
"Deploy and secure enterprise workloads on native OpenShift with developer focused tools to run highly available apps. OpenShift clusters build on Kubernetes container orchestration that offers consistency and flexibility in operations.",
},
{
href: "/",
text: "Container Registry",
description:
"Securely store container images and monitor their vulnerabilities in a private registry.",
},
{
href: "/",
text: "Code Engine",
description:
"Run your application, job, or container on a managed serverless platform.",
},
];
let ref = null;
let active = false;
let value = "";
let selectedResultIndex = 0;
let events = [];
$: lowerCaseValue = value.toLowerCase();
$: results =
value.length > 0
? data.filter((item) => {
return (
item.text.toLowerCase().includes(lowerCaseValue) ||
item.description.includes(lowerCaseValue)
);
})
: [];
$: console.log("ref", ref);
$: console.log("active", active);
$: console.log("value", value);
$: console.log("selectedResultIndex", selectedResultIndex);
</script>
<Header company="IBM" platformName="Carbon Svelte">
<div slot="skip-to-content">
<SkipToContent />
</div>
<HeaderUtilities>
<HeaderSearch
bind:ref
bind:active
bind:value
bind:selectedResultIndex
placeholder="Search services"
results="{results}"
on:active="{() => {
events = [...events, { type: 'active' }];
}}"
on:inactive="{() => {
events = [...events, { type: 'inactive' }];
}}"
on:clear="{() => {
events = [...events, { type: 'clear' }];
}}"
on:select="{(e) => {
events = [...events, { type: 'select', ...e.detail }];
}}"
/>
</HeaderUtilities>
</Header>
<Content>
<Grid>
<Row>
<Column>
<h1>HeaderSearch</h1>
<Button
on:click="{() => {
active = true;
}}"
>
Activate the search bar
</Button>
<h2>Reactive values</h2>
<p><strong>active</strong>: {active}</p>
<p><strong>value</strong>: {value}</p>
<p><strong>selectedResultIndex</strong>: {selectedResultIndex}</p>
<h2>Events</h2>
<p>
Click the button and search for something. Dispatched events are
logged below:
</p>
<div style="overflow-x: scroll;">
{#each events as { type, ...rest }}
<div style="display: block; margin-bottom: var(--cds-layout-01)">
<div><strong>on:{type}</strong></div>
{#if Object.keys(rest).length > 0}
<pre>{JSON.stringify(rest, null, 2)}</pre>
{/if}
</div>
{/each}
</div>
</Column>
</Row>
</Grid>
</Content>

View file

@ -3,7 +3,6 @@
Header,
HeaderUtilities,
HeaderAction,
HeaderActionSearch,
HeaderGlobalAction,
HeaderPanelLinks,
HeaderPanelDivider,
@ -30,7 +29,6 @@
<SkipToContent />
</div>
<HeaderUtilities>
<HeaderActionSearch />
<HeaderGlobalAction aria-label="Settings" icon="{SettingsAdjust20}" />
<HeaderAction bind:isOpen>
<HeaderPanelLinks>

View file

@ -1,4 +1,10 @@
<script>
/**
* @deprecated
* This component will be removed in version 1.0.0.
* Use `HeaderSearch` instead
*/
/**
* @event {{ action: "search"; textInput: string; }} inputSearch
*/

View file

@ -0,0 +1,284 @@
<script>
/**
* @typedef {{ href: string; text: string; description?: string; }} HeaderSearchResult
* @event {any} active
* @event {any} inactive
* @event {any} clear
* @event {{ value: string; selectedResultIndex: number; selectedResult: HeaderSearchResult }} select
* @slot {{ result: HeaderSearchResult; index: number }}
*/
/** Specify the search input value */
export let value = "";
/** Set to `true` to activate and focus the search bar */
export let active = false;
/** Obtain a reference to the input HTML element */
export let ref = null;
/**
* Render a list of search results
* @type {HeaderSearchResult[]}
*/
export let results = [];
/** Specify the selected result index */
export let selectedResultIndex = 0;
import { createEventDispatcher } from "svelte";
import Close20 from "carbon-icons-svelte/lib/Close20/Close20.svelte";
import Search20 from "carbon-icons-svelte/lib/Search20/Search20.svelte";
const dispatch = createEventDispatcher();
let refSearch = null;
function reset() {
active = false;
value = "";
selectedResultIndex = 0;
}
function selectResult() {
dispatch("select", { value, selectedResultIndex, selectedResult });
reset();
}
$: if (active && ref) ref.focus();
$: dispatch(active ? "active" : "inactive");
$: selectedResult = results[selectedResultIndex];
$: selectedId = selectedResult
? `search-menuitem-${selectedResultIndex}`
: undefined;
</script>
<style>
label {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
white-space: nowrap;
border: 0;
visibility: inherit;
clip: rect(0, 0, 0, 0);
}
[role="search"] {
position: relative;
display: flex;
max-width: 28rem;
width: 100%;
margin-left: 0.5rem;
height: 3rem;
background-color: #393939;
color: #fff;
transition: max-width 0.11s cubic-bezier(0.2, 0, 0.38, 0.9),
background 0.11s cubic-bezier(0.2, 0, 0.38, 0.9);
}
[role="search"]:not(.active) {
max-width: 3rem;
background-color: #161616;
}
[role="search"].active {
outline: 2px solid #fff;
outline-offset: -2px;
}
[role="combobox"] {
display: flex;
flex-grow: 1;
border-bottom: 1px solid #393939;
}
input {
width: 100%;
height: 3rem;
padding: 0;
font-size: 1rem;
font-weight: 400;
line-height: 1.375rem;
letter-spacing: 0;
color: #fff;
caret-color: #fff;
background-color: initial;
border: none;
outline: none;
transition: opacity 0.11s cubic-bezier(0.2, 0, 0.38, 0.9);
}
input:not(.active) {
opacity: 0;
pointer-events: none;
}
button {
width: 3rem;
height: 100%;
padding: 0;
flex-shrink: 0;
opacity: 1;
transition: background-color 0.11s cubic-bezier(0.2, 0, 0.38, 0.9),
opacity 0.11s cubic-bezier(0.2, 0, 0.38, 0.9);
}
.disabled {
border: none;
pointer-events: none;
}
[aria-label="Clear search"]:hover {
background-color: #4c4c4c;
}
.hidden {
opacity: 0;
display: none;
}
ul {
position: absolute;
z-index: 10000;
padding: 1rem 0;
left: 0;
right: 0;
top: 3rem;
background-color: #161616;
border: 1px solid #393939;
border-top: none;
box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.5);
}
[role="menuitem"] {
padding: 6px 1rem;
cursor: pointer;
font-size: 0.875rem;
font-weight: 600;
line-height: 1.29;
letter-spacing: 0.16px;
transition: all 70ms cubic-bezier(0.2, 0, 0.38, 0.9);
display: block;
text-decoration: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: #c6c6c6;
}
.selected,
[role="menuitem"]:hover {
background-color: #353535;
color: #f4f4f4;
}
[role="menuitem"] span {
font-size: 0.75rem;
font-weight: 400;
line-height: 1.34;
letter-spacing: 0.32px;
text-transform: lowercase;
color: #c6c6c6;
}
</style>
<svelte:window
on:mouseup="{({ target }) => {
if (active && !refSearch.contains(target)) active = false;
}}"
/>
<div bind:this="{refSearch}" role="search" class:active>
<label for="search-input" id="search-label">Search</label>
<div role="combobox" aria-expanded="{active}">
<button
type="button"
aria-label="Search"
tabindex="{active ? '-1' : '0'}"
class:bx--header__action="{true}"
class:disabled="{active}"
on:click="{() => {
active = true;
}}"
>
<Search20 title="Search" />
</button>
<input
bind:this="{ref}"
type="text"
autocomplete="off"
placeholder="Search..."
tabindex="{active ? '0' : '-1'}"
class:active
{...$$restProps}
id="search-input"
aria-activedescendant="{selectedId}"
bind:value
on:change
on:input
on:focus
on:blur
on:keydown
on:keydown="{({ key }) => {
switch (key) {
case 'Enter':
selectResult();
break;
case 'ArrowDown':
if (selectedResultIndex === results.length - 1) {
selectedResultIndex = 0;
} else {
selectedResultIndex += 1;
}
break;
case 'ArrowUp':
if (selectedResultIndex === 0) {
selectedResultIndex = results.length - 1;
} else {
selectedResultIndex -= 1;
}
break;
}
}}"
/>
<button
type="button"
aria-label="Clear search"
tabindex="{active ? '0' : '-1'}"
class:bx--header__action="{true}"
class:hidden="{!active}"
on:click="{() => {
reset();
dispatch('clear');
}}"
>
<Close20 title="Close" />
</button>
</div>
{#if active && results.length > 0}
<ul aria-labelledby="search-label" role="menu" id="search-menu">
{#each results as result, i}
<li>
<a
tabindex="-1"
id="search-menuitem-{i}"
role="menuitem"
href="{result.href}"
class:selected="{selectedId === `search-menuitem-${i}`}"
on:click|preventDefault="{selectResult}"
>
<slot result="{result}" index="{i}">
{result.text}
{#if result.description}<span> {result.description}</span>{/if}
</slot>
</a>
</li>
{/each}
</ul>
{/if}
</div>

View file

@ -17,3 +17,4 @@ export { default as SideNavMenuItem } from "./SideNav/SideNavMenuItem.svelte";
export { default as Content } from "./Content.svelte";
export { default as SkipToContent } from "./SkipToContent.svelte";
export { default as HeaderGlobalAction } from "./HeaderGlobalAction.svelte";
export { default as HeaderSearch } from "./HeaderSearch.svelte";

View file

@ -134,5 +134,6 @@ export {
Content,
SkipToContent,
HeaderGlobalAction,
HeaderSearch,
} from "./UIShell";
export { UnorderedList } from "./UnorderedList";

117
tests/HeaderSearch.svelte Normal file
View file

@ -0,0 +1,117 @@
<script lang="ts">
import {
Header,
HeaderUtilities,
HeaderAction,
HeaderSearch,
HeaderPanelLinks,
HeaderPanelDivider,
HeaderPanelLink,
SideNav,
SideNavItems,
SideNavMenu,
SideNavMenuItem,
SideNavLink,
SkipToContent,
Content,
Grid,
Row,
Column,
} from "../types";
let isSideNavOpen = false;
let isOpen = false;
let ref = null;
let active = false;
let value = "";
let selectedResultIndex = 1;
$: results =
value.length > 2
? [
{
href: "/",
text: "Result 1",
description: "Result description",
},
{
href: "/",
text: "Result 2",
description: "Result description",
},
{
href: "/",
text: "Result 3",
description: "Result description",
},
]
: [];
$: console.log("ref", ref);
$: console.log("active", active);
$: console.log("value", value);
$: console.log("selectedResultIndex", selectedResultIndex);
</script>
<Header company="IBM" platformName="Carbon Svelte" bind:isSideNavOpen>
<div slot="skip-to-content">
<SkipToContent />
</div>
<HeaderUtilities>
<HeaderSearch
bind:ref
bind:active
bind:value
bind:selectedResultIndex
results="{results}"
on:active
on:inactive
on:clear="{() => {
console.log('on:clear');
}}"
on:select="{(e) => {
console.log('on:select', e.detail);
}}"
let:result
let:index
>
<div>{result.text}{index}</div>
</HeaderSearch>
<HeaderAction bind:isOpen>
<HeaderPanelLinks>
<HeaderPanelDivider>Switcher subject 1</HeaderPanelDivider>
<HeaderPanelLink>Switcher item 1</HeaderPanelLink>
<HeaderPanelDivider>Switcher subject 2</HeaderPanelDivider>
<HeaderPanelLink>Switcher item 1</HeaderPanelLink>
<HeaderPanelLink>Switcher item 2</HeaderPanelLink>
<HeaderPanelLink>Switcher item 3</HeaderPanelLink>
<HeaderPanelLink>Switcher item 4</HeaderPanelLink>
<HeaderPanelLink>Switcher item 5</HeaderPanelLink>
</HeaderPanelLinks>
</HeaderAction>
</HeaderUtilities>
</Header>
<SideNav bind:isOpen="{isSideNavOpen}">
<SideNavItems>
<SideNavLink text="Link 1" />
<SideNavLink text="Link 2" />
<SideNavLink text="Link 3" />
<SideNavMenu text="Menu">
<SideNavMenuItem href="/" text="Link 1" />
<SideNavMenuItem href="/" text="Link 2" />
<SideNavMenuItem href="/" text="Link 3" />
</SideNavMenu>
</SideNavItems>
</SideNav>
<Content>
<Grid>
<Row>
<Column>
<h1>Welcome</h1>
</Column>
</Row>
</Grid>
</Content>

60
types/UIShell/HeaderSearch.d.ts vendored Normal file
View file

@ -0,0 +1,60 @@
/// <reference types="svelte" />
export interface HeaderSearchResult {
href: string;
text: string;
description?: string;
}
export interface HeaderSearchProps extends svelte.JSX.HTMLAttributes<HTMLElementTagNameMap["input"]> {
/**
* Specify the search input value
* @default ""
*/
value?: string;
/**
* Set to `true` to activate and focus the search bar
* @default false
*/
active?: boolean;
/**
* Obtain a reference to the input HTML element
* @default null
*/
ref?: null | HTMLInputElement;
/**
* Render a list of search results
* @default []
*/
results?: HeaderSearchResult[];
/**
* Specify the selected result index
* @default 0
*/
selectedResultIndex?: number;
}
export default class HeaderSearch {
$$prop_def: HeaderSearchProps;
$$slot_def: {
default: { result: HeaderSearchResult; index: number };
};
$on(eventname: "active", cb: (event: CustomEvent<any>) => void): () => void;
$on(eventname: "inactive", cb: (event: CustomEvent<any>) => void): () => void;
$on(eventname: "clear", cb: (event: CustomEvent<any>) => void): () => void;
$on(
eventname: "select",
cb: (event: CustomEvent<{ value: string; selectedResultIndex: number; selectedResult: HeaderSearchResult }>) => void
): () => void;
$on(eventname: "change", cb: (event: WindowEventMap["change"]) => void): () => void;
$on(eventname: "input", cb: (event: WindowEventMap["input"]) => void): () => void;
$on(eventname: "focus", cb: (event: WindowEventMap["focus"]) => void): () => void;
$on(eventname: "blur", cb: (event: WindowEventMap["blur"]) => void): () => void;
$on(eventname: "keydown", cb: (event: WindowEventMap["keydown"]) => void): () => void;
$on(eventname: string, cb: (event: Event) => void): () => void;
}

1
types/index.d.ts vendored
View file

@ -151,4 +151,5 @@ export { default as SideNavMenuItem } from "./UIShell/SideNav/SideNavMenuItem";
export { default as Content } from "./UIShell/Content";
export { default as SkipToContent } from "./UIShell/SkipToContent";
export { default as HeaderGlobalAction } from "./UIShell/HeaderGlobalAction";
export { default as HeaderSearch } from "./UIShell/HeaderSearch";
export { default as UnorderedList } from "./UnorderedList/UnorderedList";