The phone's top bar is about to go, and the search box is the one thing in it that is an action rather than chrome. It becomes a button in the row that already says which page you are on, opening a wa-dialog with the real search box in it. Three decisions worth the words. **A wa-dialog, and that is a mechanism rather than a taste.** wa-popup renders `<div popover="manual">` and feature-detects the Popover API, falling back to `strategy: "fixed"` where there is none -- which is Chrome 113, the reference device, since `popover` is Chrome 114. And `position: fixed` escapes ancestor overflow but not `contain: paint`, which `.main-panel` carries, so a popup-shaped search panel opened from a view's header is structurally clipped on that device. `<dialog>` / `showModal()` is Chrome 37 and uses the real top layer. No tier here can see the difference -- CI's Chromium and WebKit both have the Popover API -- so the component test asserts the *mechanism*, a native `<dialog>` in the tree, rather than the symptom. **An element, not a PageAction.** Two of the seven searchable views are detail views with no page-header; they filter on the term and say so in their own headers. Declaring search as an action would mean seven hosts each writing it out, which is a second list of searchable views, and it would put a phone mode for actions inside page-header, which that component documents its refusal to grow. search-store's own map is the condition, asked by one component placed three times. **The modal carries the real search-bar**, so there is still one debounce, one clear button and one view-scoped placeholder. Escape closes it and *keeps* the term -- the input treats Escape as "clear the search", which is right in a header where the box stays on screen and wrong in a surface whose dismissal would then discard the search.
148 lines
5.4 KiB
TypeScript
148 lines
5.4 KiB
TypeScript
/**
|
|
* The phone's way into search (#57): one button, in the row that
|
|
* already says which page you are on.
|
|
*
|
|
* **Which views show it is not a decision this component makes.**
|
|
* `store/search-store.ts` has held the map of what each view searches
|
|
* since plan 007, and #57's own Findings say so — "that is exactly the
|
|
* condition for showing the button". So this asks `isSearchableView`
|
|
* and renders nothing otherwise, and no second list of searchable views
|
|
* exists to fall out of step with the first.
|
|
*
|
|
* **It is an element rather than a `PageAction`**, and that is the
|
|
* whole reason it is a component at all. Two of the seven searchable
|
|
* views — `playlist-details` and `smart-playlist-details` — have no
|
|
* `page-header`; they filter on the term and say so in their own
|
|
* headers. Declaring search as an action would mean seven hosts each
|
|
* writing it out, which is the second list again, and it would put a
|
|
* *phone mode for actions* inside `page-header`, which that component
|
|
* documents its refusal to grow. An element three headers place is one
|
|
* statement of the rule, placed three times.
|
|
*
|
|
* It does not participate in `page-header`'s overflow measurement, for
|
|
* the reason the count and the sort control do not: it is 32px, it is
|
|
* `flex-shrink: 0`, and the header's `fits()` sees its width like any
|
|
* other child. What it must never do is collapse into the overflow
|
|
* menu — on a phone that menu is the only home for the page's actions
|
|
* already, and search would be two taps behind an ellipsis.
|
|
*/
|
|
import { LitElement, css, html, nothing } from 'lit';
|
|
import { customElement, state } from 'lit/decorators.js';
|
|
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
|
|
|
import { designTokens } from '../../styles/tokens.css';
|
|
import { PHONE_QUERY } from '@utils/breakpoints';
|
|
import { SearchController } from '@store/controllers/search-controller';
|
|
import { ICON_SEARCH } from '@utils/icon-language';
|
|
import { OPEN_SEARCH_EVENT } from './search-dialog';
|
|
|
|
@customElement('search-trigger')
|
|
export class SearchTrigger extends LitElement {
|
|
private searchCtrl = new SearchController(this);
|
|
|
|
/**
|
|
* From `matchMedia` rather than a media query, because this decides
|
|
* whether the button *exists* — `job-band`'s rule, and for the same
|
|
* consequence: a header that renders it at every width puts a
|
|
* second search affordance beside the desktop's own box.
|
|
*/
|
|
@state() private phone = false;
|
|
|
|
private media?: MediaQueryList;
|
|
|
|
static override styles = [
|
|
designTokens,
|
|
css`
|
|
:host {
|
|
display: contents;
|
|
}
|
|
|
|
button {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
/* The smallest a touch target should be. The header's
|
|
own action buttons are smaller because they carry a
|
|
label; this one is a glyph. */
|
|
min-width: 40px;
|
|
min-height: 40px;
|
|
padding: 0;
|
|
background: none;
|
|
border: 1px solid var(--yj-border-subtle, #555);
|
|
border-radius: 4px;
|
|
color: var(--yj-text-primary, #fff);
|
|
cursor: pointer;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
button:focus-visible {
|
|
outline: 2px solid var(--yj-accent, #ffd43b);
|
|
outline-offset: -1px;
|
|
}
|
|
|
|
/* A search that is *on* says so without a second control:
|
|
the page already carries "Showing albums matching ...",
|
|
and this is the button that reopens the box to change or
|
|
clear it. */
|
|
button.filtering {
|
|
border-color: var(--yj-accent, #ffd43b);
|
|
color: var(--yj-accent-text, #ffd43b);
|
|
}
|
|
`,
|
|
];
|
|
|
|
override connectedCallback(): void {
|
|
super.connectedCallback();
|
|
|
|
this.media = window.matchMedia(PHONE_QUERY);
|
|
this.phone = this.media.matches;
|
|
this.media.addEventListener('change', this.onMedia);
|
|
}
|
|
|
|
override disconnectedCallback(): void {
|
|
super.disconnectedCallback();
|
|
this.media?.removeEventListener('change', this.onMedia);
|
|
}
|
|
|
|
private onMedia = (e: MediaQueryListEvent): void => {
|
|
this.phone = e.matches;
|
|
};
|
|
|
|
private onClick = (): void => {
|
|
document.dispatchEvent(new CustomEvent(OPEN_SEARCH_EVENT));
|
|
};
|
|
|
|
override render() {
|
|
if (!this.phone || !this.searchCtrl.isSearchableView) return nothing;
|
|
|
|
const scope = this.searchCtrl.scopeLabel;
|
|
const term = this.searchCtrl.term;
|
|
|
|
// The name carries the state, because the colour cannot: a
|
|
// control that is a different colour and the same word is a
|
|
// control that says nothing to anyone not seeing it. Same rule
|
|
// `library-status.ts` states for a partial badge.
|
|
const label = term
|
|
? `Search ${scope}, showing matches for ${term}`
|
|
: `Search ${scope}`;
|
|
|
|
return html`
|
|
<button
|
|
data-testid="search-trigger"
|
|
class=${term ? 'filtering' : ''}
|
|
aria-label=${label}
|
|
title=${label}
|
|
@click=${this.onClick}
|
|
>
|
|
<wa-icon name=${ICON_SEARCH}></wa-icon>
|
|
</button>
|
|
`;
|
|
}
|
|
}
|
|
|
|
declare global {
|
|
interface HTMLElementTagNameMap {
|
|
'search-trigger': SearchTrigger;
|
|
}
|
|
}
|