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

This commit is contained in:
Eric Liu 2020-11-26 14:13:01 -08:00
commit 7d19ee8aa8
11 changed files with 595 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,47 @@ 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>-1</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: any } </code> | <code>{result.text}<br /> {#if result.description}&lt;span&gt; {result.description}&lt;/span&gt;{/if}</code> |
### Events
| Event name | Type | Detail |
| :--------- | :--------- | :---------------------------------------------------------------------------------------------- |
| clear | dispatched | <code>any</code> |
| search | dispatched | <code>{ value: string; selectedResultIndex: number; selectedResult: HeaderSearchResult }</code> |
| change | forwarded | -- |
| input | forwarded | -- |
| focus | forwarded | -- |
| blur | forwarded | -- |
| keydown | forwarded | -- |
| select | dispatched | -- |
## `HeaderUtilities`
### Props

View file

@ -1,5 +1,5 @@
{
"total": 154,
"total": 155,
"components": [
{
"moduleName": "SkeletonText",
@ -10258,6 +10258,92 @@
"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": "-1",
"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: any }"
}
],
"events": [
{ "type": "dispatched", "name": "clear", "detail": "any" },
{
"type": "dispatched",
"name": "search",
"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" },
{ "type": "dispatched", "name": "select" }
],
"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,114 @@
<script>
import {
Header,
HeaderUtilities,
HeaderAction,
HeaderSearch,
HeaderPanelLinks,
HeaderPanelDivider,
HeaderPanelLink,
SideNav,
SideNavItems,
SideNavMenu,
SideNavMenuItem,
SideNavLink,
SkipToContent,
Content,
Grid,
Row,
Column,
} from "carbon-components-svelte";
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
on:search="{(e) => {
console.log('on:search', e.detail);
}}"
on:clear="{() => {
console.log('on:clear');
}}"
on:select="{(e) => {
console.log('on:select', e.detail);
}}"
results="{results}"
bind:selectedResultIndex
/>
<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>

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,278 @@
<script>
/**
* @typedef {{ href: string; text: string; description?: string; }} HeaderSearchResult
* @event {any} clear
* @event {{ value: string; selectedResultIndex: number; selectedResult: HeaderSearchResult }} search
*/
/** 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 = -1;
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;
$: if (active && ref) ref.focus();
$: 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':
active = false;
value = '';
dispatch('select', { value, selectedResultIndex, selectedResult });
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="{() => {
active = false;
value = '';
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="{() => {
dispatch('select', {
value,
selectedResultIndex,
selectedResult,
});
}}"
>
<slot result="{result}">
{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";

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

@ -0,0 +1,59 @@
/// <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 -1
*/
selectedResultIndex?: number;
}
export default class HeaderSearch {
$$prop_def: HeaderSearchProps;
$$slot_def: {
default: { result: any };
};
$on(eventname: "clear", cb: (event: CustomEvent<any>) => void): () => void;
$on(
eventname: "search",
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: "select", cb: (event: CustomEvent<any>) => 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";