Quellcode durchsuchen

Expands search box & adds clear button

Refactors components into a main `<search-box>` element
main
Eric Amodio vor 2 Jahren
Ursprung
Commit
6ff66de923
7 geänderte Dateien mit 507 neuen und 439 gelöschten Zeilen
  1. +4
    -9
      src/webviews/apps/plus/graph/GraphWrapper.tsx
  2. +4
    -0
      src/webviews/apps/plus/graph/graph.scss
  3. +2
    -10
      src/webviews/apps/shared/components/search/react.tsx
  4. +217
    -0
      src/webviews/apps/shared/components/search/search-box.ts
  5. +72
    -43
      src/webviews/apps/shared/components/search/search-input.ts
  6. +0
    -169
      src/webviews/apps/shared/components/search/search-nav.ts

+ 4
- 9
src/webviews/apps/plus/graph/GraphWrapper.tsx Datei anzeigen

@ -28,7 +28,7 @@ import type { Subscription } from '../../../../subscription';
import { getSubscriptionTimeRemaining, SubscriptionState } from '../../../../subscription';
import { debounce } from '../../../../system/function';
import { pluralize } from '../../../../system/string';
import { SearchField, SearchNav } from '../../shared/components/search/react';
import { SearchBox } from '../../shared/components/search/react';
import type { DateTimeFormat } from '../../shared/date';
import { formatDate, fromNow } from '../../shared/date';
@ -646,18 +646,13 @@ export function GraphWrapper({
{isAllowed && (
<header className="titlebar graph-app__header">
<div className="titlebar__group">
<SearchField
value={searchQuery?.query}
onChange={e => handleSearchInput(e as CustomEvent<SearchQuery>)}
onPrevious={() => handleSearchNavigation(false)}
onNext={() => handleSearchNavigation(true)}
/>
<SearchNav
aria-label="Graph search navigation"
<SearchBox
step={searchPosition}
total={searchResultIds?.length ?? 0}
valid={Boolean(searchQuery?.query && searchQuery.query.length > 2)}
more={hasMoreSearchResults}
value={searchQuery?.query ?? ''}
onChange={e => handleSearchInput(e as CustomEvent<SearchQuery>)}
onPrevious={() => handleSearchNavigation(false)}
onNext={() => handleSearchNavigation(true)}
/>

+ 4
- 0
src/webviews/apps/plus/graph/graph.scss Datei anzeigen

@ -435,6 +435,10 @@ a {
margin: 0;
}
}
&__group {
flex: auto 1 1;
}
}
.graph-app {

+ 2
- 10
src/webviews/apps/shared/components/search/react.tsx Datei anzeigen

@ -1,21 +1,13 @@
import { provideReactWrapper } from '@microsoft/fast-react-wrapper';
import React from 'react';
import { SearchField as fieldComponent } from './search-field';
import { SearchNav as navComponent } from './search-nav';
import { SearchBox as searchBoxComponent } from './search-box';
const { wrap } = provideReactWrapper(React);
export const SearchField = wrap(fieldComponent, {
export const SearchBox = wrap(searchBoxComponent, {
events: {
onChange: 'change',
onPrevious: 'previous',
onNext: 'next',
},
});
export const SearchNav = wrap(navComponent, {
events: {
onPrevious: 'previous',
onNext: 'next',
},
});

+ 217
- 0
src/webviews/apps/shared/components/search/search-box.ts Datei anzeigen

@ -0,0 +1,217 @@
import { attr, css, customElement, FASTElement, html, observable, volatile, when } from '@microsoft/fast-element';
import { isMac } from '@env/platform';
import { pluralize } from '../../../../../system/string';
import type { Disposable } from '../../dom';
import { DOM } from '../../dom';
import { numberConverter } from '../converters/number-converter';
import '../codicon';
import './search-input';
const template = html<SearchBox>`<template>
<search-input
errorMessage="${x => x.errorMessage}"
label="${x => x.label}"
placeholder="${x => x.placeholder}"
matchAll="${x => x.matchAll}"
matchCase="${x => x.matchCase}"
matchRegex="${x => x.matchRegex}"
value="${x => x.value}"
@previous="${(x, c) => x.handlePrevious(c.event)}"
@next="${(x, c) => x.handleNext(c.event)}"
></search-input>
<div class="search-navigation" aria-label="Search navigation">
<span class="count${x => (x.total < 1 && x.valid ? ' error' : '')}">
${when(x => x.total < 1, html<SearchBox>`${x => x.formattedLabel}`)}
${when(
x => x.total > 0,
html<SearchBox>`<span aria-current="step">${x => x.step}</span> of
${x => x.total}${x => (x.more ? '+' : '')}<span class="sr-only"> ${x => x.formattedLabel}</span>`,
)}
</span>
<button
type="button"
class="button"
?disabled="${x => !x.hasPrevious}"
@click="${(x, c) => x.handlePrevious(c.event)}"
>
<code-icon
icon="arrow-up"
aria-label="Previous Match (Shift+Enter)"
title="Previous Match (Shift+Enter)"
></code-icon>
</button>
<button type="button" class="button" ?disabled="${x => !x.hasNext}" @click="${(x, c) => x.handleNext(c.event)}">
<code-icon icon="arrow-down" aria-label="Next Match (Enter)" title="Next Match (Enter)"></code-icon>
</button>
</div>
</template>`;
const styles = css`
:host {
display: inline-flex;
flex-direction: row;
align-items: center;
gap: 0.8rem;
color: var(--vscode-titleBar-inactiveForeground);
flex: auto 1 1;
}
:host(:focus) {
outline: 0;
}
.search-navigation {
display: inline-flex;
flex-direction: row;
align-items: center;
gap: 0.8rem;
color: var(--vscode-titleBar-inactiveForeground);
}
.search-navigation:focus {
outline: 0;
}
.count {
flex: none;
margin-right: 0.4rem;
font-size: 1.2rem;
min-width: 10ch;
}
.count.error {
color: var(--vscode-errorForeground);
}
.button {
width: 2.4rem;
height: 2.4rem;
padding: 0;
color: inherit;
border: none;
background: none;
text-align: center;
}
.button:focus {
outline: 1px solid var(--vscode-focusBorder);
outline-offset: -1px;
}
.button:not([disabled]) {
cursor: pointer;
}
.button:hover:not([disabled]) {
background-color: var(--vscode-titleBar-activeBackground);
}
.button > code-icon[icon='arrow-up'] {
transform: translateX(-0.1rem);
}
.sr-only {
clip: rect(0 0 0 0);
clip-path: inset(50%);
height: 1px;
overflow: hidden;
position: absolute;
white-space: nowrap;
width: 1px;
}
`;
@customElement({ name: 'search-box', template: template, styles: styles })
export class SearchBox extends FASTElement {
@observable
errorMessage = '';
@attr
label = 'Search';
@attr
placeholder = 'Search commits, e.g. "Updates dependencies" author:eamodio';
@attr
value = '';
@attr({ mode: 'boolean' })
matchAll = false;
@attr({ mode: 'boolean' })
matchCase = false;
@attr({ mode: 'boolean' })
matchRegex = true;
@attr({ converter: numberConverter })
total = 0;
@attr({ converter: numberConverter })
step = 0;
@attr({ mode: 'boolean' })
more = false;
@attr({ mode: 'boolean' })
valid = false;
@attr
resultsLabel = 'result';
@volatile
get formattedLabel() {
return pluralize(this.resultsLabel, this.total, { zero: 'No' });
}
@volatile
get hasPrevious() {
return this.total !== 0;
}
@volatile
get hasNext() {
return this.total !== 0;
}
private _disposable: Disposable | undefined;
override connectedCallback(): void {
super.connectedCallback();
this._disposable = DOM.on(window, 'keyup', e => this.handleShortcutKeys(e));
}
override disconnectedCallback(): void {
super.disconnectedCallback();
this._disposable?.dispose();
}
next() {
this.$emit('next');
}
previous() {
this.$emit('previous');
}
handleShortcutKeys(e: KeyboardEvent) {
if (
(e.key !== 'F3' && e.key !== 'g') ||
(e.key !== 'g' && (e.ctrlKey || e.metaKey || e.altKey)) ||
(e.key === 'g' && (!e.metaKey || !isMac))
) {
return;
}
if (e.shiftKey) {
this.previous();
} else {
this.next();
}
}
handlePrevious(e: Event) {
e.stopImmediatePropagation();
this.previous();
}
handleNext(e: Event) {
e.stopImmediatePropagation();
this.next();
}
}

src/webviews/apps/shared/components/search/search-field.ts → src/webviews/apps/shared/components/search/search-input.ts Datei anzeigen

@ -1,9 +1,9 @@
import { attr, css, customElement, FASTElement, html, observable } from '@microsoft/fast-element';
import { attr, css, customElement, FASTElement, html, observable, volatile } from '@microsoft/fast-element';
import type { SearchQuery } from '../../../../../git/search';
import '../codicon';
// match case is disabled unless regex is true
const template = html<SearchField>`
const template = html<SearchInput>`
<template role="search">
<label htmlFor="search">
<code-icon icon="search" aria-label="${x => x.label}" title="${x => x.label}"></code-icon>
@ -11,10 +11,11 @@ const template = html`
<div class="field">
<input
id="search"
part="search"
type="search"
spellcheck="false"
placeholder="${x => x.placeholder}"
value="${x => x.value}"
:value="${x => x.value}"
aria-valid="${x => x.errorMessage === ''}"
aria-describedby="${x => (x.errorMessage === '' ? '' : 'error')}"
@input="${(x, c) => x.handleInput(c.event)}"
@ -24,23 +25,35 @@ const template = html`
</div>
<div class="controls">
<button
class="clear-button${x => (x.value ? '' : ' clear-button__hidden')}"
type="button"
role="button"
aria-label="Clear"
title="Clear"
@click="${(x, c) => x.handleClear(c.event)}"
>
<code-icon icon="close"></code-icon>
</button>
<button
type="button"
role="checkbox"
aria-label="Match All"
title="Match All"
aria-checked="${x => x.all}"
@click="${(x, c) => x.handleAll(c.event)}"
aria-checked="${x => x.matchAll}"
@click="${(x, c) => x.handleMatchAll(c.event)}"
>
<code-icon icon="whole-word"></code-icon>
</button>
<button
type="button"
role="checkbox"
aria-label="Match Case in Regular Expression"
title="Match Case in Regular Expression"
?disabled="${x => !x.regex}"
aria-checked="${x => x.case}"
@click="${(x, c) => x.handleCase(c.event)}"
aria-label="Match Case${x =>
x.matchCaseOverride && !x.matchCase ? ' (always on without regular expressions)' : ''}"
title="Match Case${x =>
x.matchCaseOverride && !x.matchCase ? ' (always on without regular expressions)' : ''}"
?disabled="${x => !x.matchRegex}"
aria-checked="${x => x.matchCaseOverride}"
@click="${(x, c) => x.handleMatchCase(c.event)}"
>
<code-icon icon="case-sensitive"></code-icon>
</button>
@ -49,8 +62,8 @@ const template = html`
role="checkbox"
aria-label="Use Regular Expression"
title="Use Regular Expression"
aria-checked="${x => x.regex}"
@click="${(x, c) => x.handleRegex(c.event)}"
aria-checked="${x => x.matchRegex}"
@click="${(x, c) => x.handleMatchRegex(c.event)}"
>
<code-icon icon="regex"></code-icon>
</button>
@ -69,6 +82,8 @@ const styles = css`
align-items: center;
gap: 0.8rem;
position: relative;
flex: auto 1 1;
}
label {
@ -77,7 +92,7 @@ const styles = css`
.field {
position: relative;
width: 30rem;
flex: auto 1 1;
}
input {
@ -148,37 +163,44 @@ const styles = css`
width: 2rem;
height: 2rem;
padding: 0;
color: inherit;
color: var(--vscode-input-foreground);
border: none;
background: none;
text-align: center;
border-radius: 0.25rem;
}
button:focus:not([disabled]) {
button[role='checkbox']:focus:not([disabled]) {
outline: 1px solid var(--vscode-focusBorder);
outline-offset: -1px;
}
button:not([disabled]) {
cursor: pointer;
}
button:hover:not([disabled]) {
button:hover:not([disabled]):not([aria-checked='true']) {
background-color: var(--vscode-inputOption-hoverBackground);
}
button[disabled] {
opacity: 0.5;
}
button[disabled][aria-checked='true'] {
opacity: 0.8;
}
button[aria-checked='true'] {
background-color: var(--vscode-inputOption-activeBackground);
color: var(--vscode-inputOption-activeForeground);
}
.clear-button__hidden {
display: none;
}
`;
@customElement({
name: 'search-field',
name: 'search-input',
template: template,
styles: styles,
})
export class SearchField extends FASTElement {
export class SearchInput extends FASTElement {
@observable
errorMessage = '';
@ -192,13 +214,23 @@ export class SearchField extends FASTElement {
value = '';
@attr({ mode: 'boolean' })
all = false;
matchAll = false;
@attr({ mode: 'boolean' })
case = false;
matchCase = false;
@attr({ mode: 'boolean' })
regex = true;
matchRegex = true;
@volatile
get matchCaseOverride() {
return this.matchRegex ? this.matchCase : true;
}
handleClear(_e: Event) {
this.value = '';
this.emitSearch();
}
handleInput(e: Event) {
const value = (e.target as HTMLInputElement)?.value;
@ -206,41 +238,38 @@ export class SearchField extends FASTElement {
this.emitSearch();
}
handleShortcutKeys(e: KeyboardEvent) {
if (e.key !== 'Enter' || e.ctrlKey || e.metaKey || e.altKey) return;
e.preventDefault();
if (e.shiftKey) {
this.$emit('previous');
} else {
this.$emit('next');
}
handleMatchAll(_e: Event) {
this.matchAll = !this.matchAll;
this.emitSearch();
}
handleAll(_e: Event) {
this.all = !this.all;
handleMatchCase(_e: Event) {
this.matchCase = !this.matchCase;
this.emitSearch();
}
handleCase(_e: Event) {
this.case = !this.case;
handleMatchRegex(_e: Event) {
this.matchRegex = !this.matchRegex;
this.emitSearch();
}
handleRegex(_e: Event) {
this.regex = !this.regex;
if (!this.regex) {
this.case = false;
handleShortcutKeys(e: KeyboardEvent) {
if (e.key !== 'Enter' || e.ctrlKey || e.metaKey || e.altKey) return;
e.preventDefault();
if (e.shiftKey) {
this.$emit('previous');
} else {
this.$emit('next');
}
this.emitSearch();
}
emitSearch() {
private emitSearch() {
const search: SearchQuery = {
query: this.value,
matchAll: this.all,
matchCase: this.case,
matchRegex: this.regex,
matchAll: this.matchAll,
matchCase: this.matchCase,
matchRegex: this.matchRegex,
};
this.$emit('change', search);
}

+ 0
- 169
src/webviews/apps/shared/components/search/search-nav.ts Datei anzeigen

@ -1,169 +0,0 @@
import { attr, css, customElement, FASTElement, html, volatile, when } from '@microsoft/fast-element';
import { isMac } from '@env/platform';
import { pluralize } from '../../../../../system/string';
import type { Disposable } from '../../../shared/dom';
import { DOM } from '../../../shared/dom';
import { numberConverter } from '../converters/number-converter';
import '../codicon';
const template = html<SearchNav>`<template>
<span class="count${x => (x.total < 1 && x.valid ? ' error' : '')}">
${when(x => x.total < 1, html<SearchNav>`${x => x.formattedLabel}`)}
${when(
x => x.total > 0,
html<SearchNav>`<span aria-current="step">${x => x.step}</span> of
${x => x.total}${x => (x.more ? '+' : '')}<span class="sr-only"> ${x => x.formattedLabel}</span>`,
)}
</span>
<button
type="button"
class="button"
?disabled="${x => !x.hasPrevious}"
@click="${(x, c) => x.handlePrevious(c.event)}"
>
<code-icon
icon="arrow-up"
aria-label="Previous Match (Shift+Enter)"
title="Previous Match (Shift+Enter)"
></code-icon>
</button>
<button type="button" class="button" ?disabled="${x => !x.hasNext}" @click="${(x, c) => x.handleNext(c.event)}">
<code-icon icon="arrow-down" aria-label="Next Match (Enter)" title="Next Match (Enter)"></code-icon>
</button>
</template>`;
const styles = css`
:host {
display: inline-flex;
flex-direction: row;
align-items: center;
/* gap: 0.8rem; */
color: var(--vscode-titleBar-inactiveForeground);
}
:host(:focus) {
outline: 0;
}
.count {
flex: none;
margin-right: 0.4rem;
font-size: 1.2rem;
min-width: 10ch;
}
.count.error {
color: var(--vscode-errorForeground);
}
.button {
width: 2.4rem;
height: 2.4rem;
padding: 0;
color: inherit;
border: none;
background: none;
text-align: center;
}
.button:focus {
outline: 1px solid var(--vscode-focusBorder);
outline-offset: -1px;
}
.button:not([disabled]) {
cursor: pointer;
}
.button:hover:not([disabled]) {
background-color: var(--vscode-titleBar-activeBackground);
}
.button > code-icon[icon='arrow-up'] {
transform: translateX(-0.1rem);
}
.sr-only {
clip: rect(0 0 0 0);
clip-path: inset(50%);
height: 1px;
overflow: hidden;
position: absolute;
white-space: nowrap;
width: 1px;
}
`;
@customElement({ name: 'search-nav', template: template, styles: styles })
export class SearchNav extends FASTElement {
@attr({ converter: numberConverter })
total = 0;
@attr({ converter: numberConverter })
step = 0;
@attr({ mode: 'boolean' })
more = false;
@attr({ mode: 'boolean' })
valid = false;
@attr
label = 'result';
@volatile
get formattedLabel() {
return pluralize(this.label, this.total, { zero: 'No' });
}
@volatile
get hasPrevious() {
return this.total !== 0;
}
@volatile
get hasNext() {
return this.total !== 0;
}
private _disposable: Disposable | undefined;
override connectedCallback(): void {
super.connectedCallback();
this._disposable = DOM.on(window, 'keyup', e => this.handleShortcutKeys(e));
}
override disconnectedCallback(): void {
super.disconnectedCallback();
this._disposable?.dispose();
}
next() {
this.$emit('next');
}
previous() {
this.$emit('previous');
}
handleShortcutKeys(e: KeyboardEvent) {
if (
(e.key !== 'F3' && e.key !== 'g') ||
(e.key !== 'g' && (e.ctrlKey || e.metaKey || e.altKey)) ||
(e.key === 'g' && (!e.metaKey || !isMac))
) {
return;
}
if (e.shiftKey) {
this.previous();
} else {
this.next();
}
}
handlePrevious(_e: Event) {
this.previous();
}
handleNext(_e: Event) {
this.next();
}
}

Laden…
Abbrechen
Speichern