mirror of
https://github.com/material-components/material-web.git
synced 2024-09-11 13:46:10 +03:00
Merge pull request #5275 from material-components:stories-page
PiperOrigin-RevId: 590302109
This commit is contained in:
commit
8e75343bbd
@ -51,7 +51,7 @@ function playgroundExample(eleventyConfig) {
|
||||
<md-icon aria-hidden="true">expand_more</md-icon>
|
||||
<md-icon aria-hidden="true" slot="selected">expand_less</md-icon>
|
||||
</md-outlined-icon-button>
|
||||
Expand interactive demo.
|
||||
View interactive demo inline.
|
||||
</summary>
|
||||
<lit-island on:visible import="/js/hydration-entrypoints/playground-elements.js" class="example" aria-hidden="true">
|
||||
<playground-project
|
||||
@ -68,6 +68,7 @@ function playgroundExample(eleventyConfig) {
|
||||
><md-circular-progress indeterminate></md-circular-progress></playground-file-editor>
|
||||
</lit-island>
|
||||
</details>
|
||||
<p><a href="./stories/" target="_blank">Open interactive demo in new tab.</a></p>
|
||||
`;
|
||||
});
|
||||
}
|
||||
|
34
catalog/site/css/stories.css
Normal file
34
catalog/site/css/stories.css
Normal file
@ -0,0 +1,34 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2023 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
#dragbar {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
#editor {
|
||||
margin-block: 0;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#editor-wrapper {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
height: 100dvh;
|
||||
}
|
||||
|
||||
#preview {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#preview md-circular-progress {
|
||||
inset: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
@ -50,6 +50,7 @@
|
||||
|
||||
/* Formats the code boxes themselves */
|
||||
.example playground-file-editor,
|
||||
playground-file-editor,
|
||||
pre[class*='language-'] {
|
||||
padding: var(--__code-block-font-size);
|
||||
/* Remove the extra hard coded 3px from line number padding. */
|
||||
|
97
catalog/site/stories/stories.html
Normal file
97
catalog/site/stories/stories.html
Normal file
@ -0,0 +1,97 @@
|
||||
---js
|
||||
{
|
||||
pagination: {
|
||||
data: "collections.component",
|
||||
size: 1,
|
||||
alias: "component",
|
||||
before: components => {
|
||||
// remove any components that don't have a dirname
|
||||
return components.filter(component => component.data.dirname)
|
||||
}
|
||||
},
|
||||
permalink: "components/{{component.data.page.fileSlug}}/stories/index.html",
|
||||
fullHeightContent: "true",
|
||||
collections: ["stories"],
|
||||
eleventyComputed: {
|
||||
dirname: ({component}) => component.data.dirname,
|
||||
}
|
||||
}
|
||||
---
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes"
|
||||
/>
|
||||
<title>Material Web - Stories {{component.data.name}}</title>
|
||||
<!-- Set the color of the url bar on mobile to match theme -->
|
||||
<meta name="theme-color" content="#251f16" />
|
||||
<link
|
||||
href="/images/favicon.svg"
|
||||
rel="icon"
|
||||
sizes="any"
|
||||
type="image/svg+xml"
|
||||
/>
|
||||
<!-- Inlines the global css in site/css/global.css -->
|
||||
{% inlinecss "global.css" %}
|
||||
<!-- MUST be loaded before any lit bundle. allows hydration of SSRd components -->
|
||||
<script type="module" src="/js/ssr-utils/lit-hydrate-support.js"></script>
|
||||
<!-- Inlines the material theming logic since we want to prevent FOUC -->
|
||||
{% inlinejs "inline/apply-saved-theme.js" %}
|
||||
<!-- Needed for intializing theme if this is the first page they ever visit -->
|
||||
<script type="module" src="/js/pages/global.js"></script>
|
||||
<noscript>
|
||||
<link rel="stylesheet" href="/css/no-js.css" />
|
||||
</noscript>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined&display=swap"
|
||||
/>
|
||||
<!-- If JS is disabled just show the contents without the polyfill -->
|
||||
<noscript
|
||||
><style>
|
||||
body[dsd-pending] {
|
||||
display: block !important;
|
||||
}
|
||||
</style>
|
||||
</noscript>
|
||||
<!-- Syncs theme with playground -->
|
||||
<script type="module" src="/js/pages/components.js"></script>
|
||||
<script type="module" src="/js/pages/stories.js"></script>
|
||||
<script
|
||||
type="module"
|
||||
src="/js/hydration-entrypoints/playground-elements.js"
|
||||
></script>
|
||||
<link rel="stylesheet" href="/css/syntax-highlight.css" />
|
||||
<link rel="stylesheet" href="/css/stories.css" />
|
||||
</head>
|
||||
<!-- dsd-pending hides body until the polyfill has run on browsers that do not support DSD -->
|
||||
<body dsd-pending>
|
||||
<!-- Inlines the declarative shadow dom polyfill for FF since it's performance sensitive -->
|
||||
{% inlinejs "ssr-utils/dsd-polyfill.js" %}
|
||||
<playground-project
|
||||
id="project"
|
||||
project-src="/assets/stories/{{dirname}}/project.json"
|
||||
>
|
||||
</playground-project>
|
||||
<drag-playground id="dragbar">
|
||||
<playground-preview id="preview" project="project" slot="preview">
|
||||
<md-circular-progress indeterminate></md-circular-progress>
|
||||
</playground-preview>
|
||||
<div slot="editor" id="editor-wrapper">
|
||||
<playground-file-editor
|
||||
id="editor"
|
||||
project="project"
|
||||
filename="stories.ts"
|
||||
line-numbers
|
||||
aria-hidden="true"
|
||||
>
|
||||
<md-circular-progress indeterminate></md-circular-progress>
|
||||
</playground-file-editor>
|
||||
</div>
|
||||
</drag-playground>
|
||||
</body>
|
||||
</html>
|
216
catalog/src/components/drag-playground.ts
Normal file
216
catalog/src/components/drag-playground.ts
Normal file
@ -0,0 +1,216 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2023 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
|
||||
import { LitElement, css, html } from 'lit';
|
||||
import { customElement, state, query } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
import '@material/web/icon/icon.js';
|
||||
|
||||
/**
|
||||
* A playground preview + editor with a draggable handle.
|
||||
*/
|
||||
@customElement('drag-playground')
|
||||
export class DragPlayground extends LitElement {
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
--_drag-bar-height: 24px;
|
||||
--_drag-bar-border-width: 1px;
|
||||
--_half-drag-bar-height: calc(
|
||||
(var(--_drag-bar-height) / 2) + var(--_drag-bar-border-width)
|
||||
);
|
||||
}
|
||||
|
||||
#wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
:host,
|
||||
#wrapper,
|
||||
::slotted(*) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
slot {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
[name='preview'] {
|
||||
height: max(
|
||||
calc(
|
||||
100% - var(--editor-percentage, 0%) - var(--_half-drag-bar-height)
|
||||
),
|
||||
0px
|
||||
);
|
||||
}
|
||||
|
||||
[name='editor'] {
|
||||
height: max(
|
||||
calc(var(--editor-percentage, 0px) - var(--_half-drag-bar-height)),
|
||||
0px
|
||||
);
|
||||
}
|
||||
|
||||
#drag-bar {
|
||||
touch-action: none;
|
||||
background-color: var(--md-sys-color-surface-container);
|
||||
color: var(--md-sys-color-on-surface);
|
||||
border: var(--_drag-bar-border-width) solid var(--md-sys-color-outline);
|
||||
border-radius: 12px;
|
||||
height: var(--_drag-bar-height);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
#drag-bar:hover {
|
||||
background-color: var(--md-sys-color-surface-container-high);
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
#drag-bar.isDragging {
|
||||
background-color: var(--md-sys-color-inverse-surface);
|
||||
color: var(--md-sys-color-inverse-on-surface);
|
||||
cursor: grabbing;
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* Whether or not we are in the "dragging" state.
|
||||
*/
|
||||
@state() private isDragging = false;
|
||||
|
||||
/**
|
||||
* The percentage of the editor height.
|
||||
*/
|
||||
@state() private editorHeightPercent = 0;
|
||||
|
||||
@query('#wrapper') private wrapperEl!: HTMLElement;
|
||||
|
||||
/**
|
||||
* A set of pointer IDs in the case that the user is dragging with multiple
|
||||
* pointers.
|
||||
*/
|
||||
private pointerIds = new Set<number>();
|
||||
|
||||
render() {
|
||||
return html`<div
|
||||
id="wrapper"
|
||||
style=${styleMap({
|
||||
'--editor-percentage': `${this.editorHeightPercent}%`,
|
||||
})}
|
||||
>
|
||||
<slot name="preview"></slot>
|
||||
<div
|
||||
id="drag-bar"
|
||||
tabindex="0"
|
||||
role="slider"
|
||||
aria-orientation="vertical"
|
||||
aria-valuemax="100"
|
||||
aria-valuemin="0"
|
||||
aria-valuenow="${this.editorHeightPercent}"
|
||||
aria-valuetext="${this.editorHeightPercent} percent"
|
||||
aria-label="Editor height"
|
||||
@focus=${this.onFocus}
|
||||
@blur=${this.onBlur}
|
||||
@keydown=${this.onKeydown}
|
||||
@pointerdown=${this.onPointerdown}
|
||||
@pointerup=${this.onPointerup}
|
||||
@pointermove=${this.onPointermove}
|
||||
class=${classMap({
|
||||
isDragging: this.isDragging,
|
||||
})}
|
||||
>
|
||||
<md-icon>drag_handle</md-icon>
|
||||
</div>
|
||||
<slot name="editor"></slot>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private onFocus() {
|
||||
this.isDragging = true;
|
||||
}
|
||||
|
||||
private onBlur() {
|
||||
this.isDragging = false;
|
||||
}
|
||||
|
||||
private onKeydown(event: KeyboardEvent) {
|
||||
const { key } = event;
|
||||
switch (key) {
|
||||
case 'ArrowRight':
|
||||
case 'ArrowUp':
|
||||
this.editorHeightPercent = Math.min(this.editorHeightPercent + 1, 100);
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
case 'ArrowDown':
|
||||
this.editorHeightPercent = Math.max(this.editorHeightPercent - 1, 0);
|
||||
break;
|
||||
case 'PageUp':
|
||||
this.editorHeightPercent = Math.min(this.editorHeightPercent + 10, 100);
|
||||
break;
|
||||
case 'PageDown':
|
||||
this.editorHeightPercent = Math.max(this.editorHeightPercent - 10, 0);
|
||||
break;
|
||||
case 'Home':
|
||||
this.editorHeightPercent = 0;
|
||||
break;
|
||||
case 'End':
|
||||
this.editorHeightPercent = 100;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private onPointerdown(event: PointerEvent) {
|
||||
this.isDragging = true;
|
||||
|
||||
if (this.pointerIds.has(event.pointerId)) return;
|
||||
|
||||
this.pointerIds.add(event.pointerId);
|
||||
(event.target as HTMLElement).setPointerCapture(event.pointerId);
|
||||
}
|
||||
|
||||
private onPointerup(event: PointerEvent) {
|
||||
this.pointerIds.delete(event.pointerId);
|
||||
(event.target as HTMLElement).releasePointerCapture(event.pointerId);
|
||||
|
||||
if (this.pointerIds.size === 0) {
|
||||
this.isDragging = false;
|
||||
}
|
||||
}
|
||||
|
||||
private onPointermove(event: PointerEvent) {
|
||||
if (!this.isDragging) return;
|
||||
|
||||
const { clientY: mouseY } = event;
|
||||
const { top: wrapperTop, bottom: wrapperBottom } =
|
||||
this.wrapperEl.getBoundingClientRect();
|
||||
|
||||
// The height of the wrapper
|
||||
const height = wrapperBottom - wrapperTop;
|
||||
|
||||
// Calculate the percentage of the editor height in which the pointer is
|
||||
// located
|
||||
const editorHeightPercent = 100 - ((mouseY - wrapperTop) / height) * 100;
|
||||
|
||||
// Clamp the percentage between 0 and 100
|
||||
this.editorHeightPercent = Math.min(Math.max(editorHeightPercent, 0), 100);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'drag-playground': DragPlayground;
|
||||
}
|
||||
}
|
7
catalog/src/pages/stories.ts
Normal file
7
catalog/src/pages/stories.ts
Normal file
@ -0,0 +1,7 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2023 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import '../components/drag-playground.js';
|
@ -12,5 +12,6 @@ import './components/catalog-component-header-title.js';
|
||||
import './components/nav-drawer.js';
|
||||
import './components/theme-changer.js';
|
||||
import './components/top-app-bar.js';
|
||||
import './components/drag-playground.js';
|
||||
// 🤫
|
||||
import '@material/web/labs/item/item.js';
|
||||
|
@ -13,11 +13,11 @@ import '@material/web/select/filled-select.js';
|
||||
import '@material/web/select/select-option.js';
|
||||
import '@material/web/textfield/filled-text-field.js';
|
||||
|
||||
import {css, html, LitElement} from 'lit';
|
||||
import {customElement, property} from 'lit/decorators.js';
|
||||
import {StyleInfo, styleMap} from 'lit/directives/style-map.js';
|
||||
import { css, html, LitElement } from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { StyleInfo, styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import {Knob, KnobUi} from '../knobs.js';
|
||||
import { Knob, KnobUi } from '../knobs.js';
|
||||
|
||||
/**
|
||||
* A boolean Knob UI.
|
||||
@ -36,7 +36,8 @@ export function boolInput(): KnobUi<boolean> {
|
||||
touch-target="none"
|
||||
style="margin-inline-end: 16px;"
|
||||
.checked=${!!knob.latestValue}
|
||||
@change="${valueChanged}">
|
||||
@change="${valueChanged}"
|
||||
>
|
||||
</md-checkbox>
|
||||
${knob.name}
|
||||
</label>
|
||||
@ -51,7 +52,7 @@ export function boolInput(): KnobUi<boolean> {
|
||||
*/
|
||||
@customElement('knob-color-selector')
|
||||
export class KnobColorSelector extends LitElement {
|
||||
static override styles = css`
|
||||
static styles = css`
|
||||
:host {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
@ -111,7 +112,7 @@ export class KnobColorSelector extends LitElement {
|
||||
|
||||
private internalValue = '';
|
||||
|
||||
@property({type: Boolean}) hasAlpha = false;
|
||||
@property({ type: Boolean }) hasAlpha = false;
|
||||
|
||||
set value(val: string) {
|
||||
const oldVal = this.internalValue;
|
||||
@ -119,12 +120,12 @@ export class KnobColorSelector extends LitElement {
|
||||
this.requestUpdate('value', oldVal);
|
||||
}
|
||||
|
||||
@property({type: String, reflect: true})
|
||||
@property({ type: String, reflect: true })
|
||||
get value() {
|
||||
return this.internalValue;
|
||||
}
|
||||
|
||||
override render() {
|
||||
render() {
|
||||
return html`<span id="wrapper">
|
||||
<span>
|
||||
${this.hasAlpha ? this.renderTextInput() : this.renderColorInput()}
|
||||
@ -132,7 +133,8 @@ export class KnobColorSelector extends LitElement {
|
||||
<md-outlined-button
|
||||
@click=${() => {
|
||||
this.hasAlpha = !this.hasAlpha;
|
||||
}}>
|
||||
}}
|
||||
>
|
||||
${this.hasAlpha ? 'rgba' : 'rgb'}
|
||||
</md-outlined-button>
|
||||
</span>`;
|
||||
@ -143,7 +145,8 @@ export class KnobColorSelector extends LitElement {
|
||||
style=${styleMap(sharedTextFieldStyles)}
|
||||
.value=${this.value}
|
||||
@change=${this.propagateEvt}
|
||||
@input=${this.onInput}></md-filled-text-field>`;
|
||||
@input=${this.onInput}
|
||||
></md-filled-text-field>`;
|
||||
}
|
||||
|
||||
private renderColorInput() {
|
||||
@ -158,7 +161,8 @@ export class KnobColorSelector extends LitElement {
|
||||
id="color-picker"
|
||||
.value=${this.value}
|
||||
@change=${this.propagateEvt}
|
||||
@input=${this.onInput} />
|
||||
@input=${this.onInput}
|
||||
/>
|
||||
`;
|
||||
}
|
||||
|
||||
@ -176,17 +180,17 @@ export class KnobColorSelector extends LitElement {
|
||||
this.dispatchEvent(newEvt);
|
||||
}
|
||||
|
||||
override click() {
|
||||
click() {
|
||||
const input = this.renderRoot!.querySelector(
|
||||
'input,md-filled-text-field',
|
||||
'input,md-filled-text-field'
|
||||
) as HTMLElement;
|
||||
input.click();
|
||||
input.focus();
|
||||
}
|
||||
|
||||
override focus() {
|
||||
focus() {
|
||||
const input = this.renderRoot!.querySelector(
|
||||
'input,md-filled-text-field',
|
||||
'input,md-filled-text-field'
|
||||
) as HTMLElement;
|
||||
input.focus();
|
||||
}
|
||||
@ -226,7 +230,8 @@ export function colorPicker(opts?: ColorPickerOpts): KnobUi<string> {
|
||||
<knob-color-selector
|
||||
.value="${knob.latestValue ?? ''}"
|
||||
.hasAlpha="${config.hasAlpha}"
|
||||
@input=${valueChanged}></knob-color-selector>
|
||||
@input=${valueChanged}
|
||||
></knob-color-selector>
|
||||
${knob.name}
|
||||
</label>
|
||||
</div>
|
||||
@ -245,7 +250,7 @@ const sharedTextFieldStyles: StyleInfo = {
|
||||
'--md-filled-field-trailing-space': '8px',
|
||||
'--md-filled-field-top-space': '4px',
|
||||
'--md-filled-field-bottom-space': '4px',
|
||||
'width': '150px',
|
||||
width: '150px',
|
||||
'min-width': '150px',
|
||||
};
|
||||
|
||||
@ -272,7 +277,8 @@ export function textInput<T>(options?: TextInputOptions<T>): KnobUi<T> {
|
||||
<md-filled-text-field
|
||||
style=${styleMap(sharedTextFieldStyles)}
|
||||
.value="${(knob.latestValue ?? '') as unknown as string}"
|
||||
@input="${valueChanged}"></md-filled-text-field>
|
||||
@input="${valueChanged}"
|
||||
></md-filled-text-field>
|
||||
${knob.name}
|
||||
</label>
|
||||
</div>
|
||||
@ -313,7 +319,8 @@ export function numberInput(opts?: NumberInputOpts): KnobUi<number> {
|
||||
type="number"
|
||||
step="${config.step}"
|
||||
.value="${knob.latestValue ? knob.latestValue.toString() : '0'}"
|
||||
@input="${valueChanged}"></md-filled-text-field>
|
||||
@input="${valueChanged}"
|
||||
></md-filled-text-field>
|
||||
${knob.name}
|
||||
</label>
|
||||
</div>
|
||||
@ -333,7 +340,7 @@ export function button(): KnobUi<number> {
|
||||
const count = knob.latestValue ?? 0;
|
||||
onChange(count + 1);
|
||||
};
|
||||
const styles = styleMap({display: 'inline-block'});
|
||||
const styles = styleMap({ display: 'inline-block' });
|
||||
return html`
|
||||
<md-outlined-button outlined @click=${onClick} style=${styles}>
|
||||
${knob.name}
|
||||
@ -344,7 +351,10 @@ export function button(): KnobUi<number> {
|
||||
}
|
||||
|
||||
interface RadioSelectorConfig<T extends string> {
|
||||
readonly options: ReadonlyArray<{readonly value: T; readonly label: string}>;
|
||||
readonly options: ReadonlyArray<{
|
||||
readonly value: T;
|
||||
readonly label: string;
|
||||
}>;
|
||||
readonly name: string;
|
||||
}
|
||||
|
||||
@ -365,7 +375,8 @@ export function radioSelector<T extends string>({
|
||||
name="${name}"
|
||||
value="${value}"
|
||||
@change="${valueChanged}"
|
||||
?checked="${knob.latestValue === option.value}"></md-radio>
|
||||
?checked="${knob.latestValue === option.value}"
|
||||
></md-radio>
|
||||
${option.label}
|
||||
</label>`;
|
||||
});
|
||||
@ -375,7 +386,10 @@ export function radioSelector<T extends string>({
|
||||
}
|
||||
|
||||
interface SelectDropdownConfig<T extends string> {
|
||||
readonly options: ReadonlyArray<{readonly value: T; readonly label: string}>;
|
||||
readonly options: ReadonlyArray<{
|
||||
readonly value: T;
|
||||
readonly label: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
/** A select dropdown Knob UI. */
|
||||
@ -391,14 +405,17 @@ export function selectDropdown<T extends string>({
|
||||
return html`<md-select-option
|
||||
?selected="${knob.latestValue === option.value}"
|
||||
.value="${option.value}"
|
||||
.headline=${option.label}></md-select-option>`;
|
||||
>
|
||||
<div slot="headline">${option.label}</div>
|
||||
</md-select-option>`;
|
||||
});
|
||||
return html`
|
||||
<label>
|
||||
<md-filled-select
|
||||
@change="${valueChanged}"
|
||||
menu-positioning="fixed"
|
||||
style=${styleMap(sharedTextFieldStyles)}>
|
||||
style=${styleMap(sharedTextFieldStyles)}
|
||||
>
|
||||
${listItems}
|
||||
</md-filled-select>
|
||||
${knob.name}
|
||||
@ -415,7 +432,7 @@ export function selectDropdown<T extends string>({
|
||||
export function cssCustomProperty(
|
||||
knob: Knob<string>,
|
||||
val: string,
|
||||
containerOfRenderedStory: HTMLElement,
|
||||
containerOfRenderedStory: HTMLElement
|
||||
) {
|
||||
const value = knob.isUnset ? knob.defaultValue : val;
|
||||
if (value) {
|
||||
|
Loading…
Reference in New Issue
Block a user