Implement block previews in StreamField block chooser

pull/12716/head
Sage Abdullah 2024-12-16 16:49:19 +00:00
rodzic efc73a649d
commit d25a67a4b8
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: EB1A33CC51CC0217
11 zmienionych plików z 363 dodań i 248 usunięć

Wyświetl plik

@ -56,6 +56,7 @@ module.exports = {
'none',
'unset',
'transparent',
'normal',
// System colors for forced-colors styling.
// See https://drafts.csswg.org/css-color-4/#css-system-colors.
'Canvas',

Wyświetl plik

@ -80,6 +80,7 @@ These are classes for components.
@import '../src/components/LoadingSpinner/LoadingSpinner';
@import '../src/components/PublicationStatus/PublicationStatus';
@import '../src/components/ComboBox/ComboBox';
@import '../src/components/ComboBoxPreview/ComboBoxPreview';
@import '../src/components/PageExplorer/PageExplorer';
@import '../src/components/CommentApp/main';

Wyświetl plik

@ -3,15 +3,21 @@
$spacing: theme('spacing.[2.5]');
$spacing-sm: theme('spacing.5');
.w-combobox {
width: min(400px, 80vw);
.w-combobox-container {
@include dark-theme() {
background-color: theme('colors.surface-tooltip');
}
display: grid;
grid-template-columns: 1fr;
@include media-breakpoint-up(sm) {
grid-template-columns: min(400px, 80vw) 1fr;
}
min-height: min(320px, 70vh);
background: theme('colors.surface-page');
color: theme('colors.text-context');
border-radius: theme('borderRadius.DEFAULT');
font-size: theme('fontSize.18');
box-shadow: theme('boxShadow.md');
outline: 10px solid transparent;
}
@ -19,6 +25,7 @@ $spacing-sm: theme('spacing.5');
.w-combobox__field {
padding: $spacing;
padding-bottom: 0;
font-size: theme('fontSize.18');
@include media-breakpoint-up(sm) {
padding: $spacing-sm;
@ -48,7 +55,7 @@ $spacing-sm: theme('spacing.5');
padding-top: 0;
@include media-breakpoint-up(sm) {
width: 400px;
width: 100%;
padding: $spacing-sm;
padding-top: 0;
}

Wyświetl plik

@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react';
import { useCombobox, UseComboboxStateChange } from 'downshift';
import { gettext } from '../../utils/gettext';
import ComboBoxPreview from '../ComboBoxPreview/ComboBoxPreview';
import Icon from '../Icon/Icon';
import findMatches from './findMatches';
@ -21,6 +22,7 @@ export interface ComboBoxItem {
label?: string | null;
description?: string | null;
icon?: string | JSX.Element | null;
blockDefId?: string;
category?: string;
render?: (props: { option: ComboBoxItem }) => JSX.Element | string;
}
@ -84,6 +86,7 @@ export default function ComboBox<ComboBoxOption extends ComboBoxItem>({
getMenuProps,
getInputProps,
getItemProps,
highlightedIndex,
setHighlightedIndex,
setInputValue,
openMenu,
@ -151,6 +154,9 @@ export default function ComboBox<ComboBoxOption extends ComboBoxItem>({
},
});
const [lastHighlightedIndex, setLastHighlightedIndex] =
useState(highlightedIndex);
useEffect(() => {
if (inputValue) {
openMenu();
@ -170,96 +176,111 @@ export default function ComboBox<ComboBoxOption extends ComboBoxItem>({
}
}, [inputValue]);
if (
inputItems[highlightedIndex] &&
highlightedIndex !== lastHighlightedIndex
) {
setLastHighlightedIndex(highlightedIndex);
}
const selectedBlock =
inputItems[highlightedIndex] || inputItems[lastHighlightedIndex];
return (
<div className="w-combobox">
{/* downshift does the label-field association itself. */}
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
<label {...getLabelProps()} className="w-sr-only">
{label}
</label>
<div className="w-combobox__field">
<input
{...getInputProps()}
type="text"
// Prevent the field from receiving focus if its not visible.
disabled={inlineCombobox}
placeholder={placeholder}
/>
</div>
{noResults ? (
<div className="w-combobox__status">{noResultsText}</div>
) : null}
<div {...getMenuProps()} className="w-combobox__menu">
{categories.map((category) => {
const categoryItems = (category.items || []).filter((item) =>
inputItems.find((i) => i.type === item.type),
);
const itemColumns = Math.ceil(categoryItems.length / 2);
<div className="w-combobox-container">
<div className="w-combobox">
{/* downshift does the label-field association itself. */}
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
<label {...getLabelProps()} className="w-sr-only">
{label}
</label>
<div className="w-combobox__field">
<input
{...getInputProps()}
type="text"
// Prevent the field from receiving focus if its not visible.
disabled={inlineCombobox}
placeholder={placeholder}
/>
</div>
{noResults ? (
<div className="w-combobox__status">{noResultsText}</div>
) : null}
<div {...getMenuProps()} className="w-combobox__menu">
{categories.map((category) => {
const categoryItems = (category.items || []).filter((item) =>
inputItems.find((i) => i.type === item.type),
);
const itemColumns = Math.ceil(categoryItems.length / 2);
if (categoryItems.length === 0) {
return null;
}
if (categoryItems.length === 0) {
return null;
}
return (
<div className="w-combobox__optgroup" key={category.type}>
{category.label ? (
<div className="w-combobox__optgroup-label">
{category.label}
</div>
) : null}
{categoryItems.map((item, index) => {
const itemLabel = getItemLabel(item.type, item);
const description = getItemDescription(item);
const itemIndex = inputItems.findIndex(
(i) => i.type === item.type,
);
const itemColumn = index + 1 <= itemColumns ? 1 : 2;
const hasIcon =
typeof item.icon !== 'undefined' && item.icon !== null;
let icon: JSX.Element | null | undefined = null;
if (hasIcon) {
if (Array.isArray(item.icon)) {
icon = (
<Icon name="custom" viewBox="0 0 1024 1024">
{item.icon.map((pathData: string) => (
<path key={pathData} d={pathData} />
))}
</Icon>
);
} else {
icon =
typeof item.icon === 'string' ? (
<Icon name={item.icon} />
) : (
item.icon
);
}
}
return (
<div
key={item.type}
{...getItemProps({ item, index: itemIndex })}
className={`w-combobox__option w-combobox__option--col${itemColumn}`}
>
<div className="w-combobox__option-icon">
{icon}
{/* Support for rich text options using text as an icon (for example "B" for bold). */}
{itemLabel && !hasIcon ? <span>{itemLabel}</span> : null}
</div>
<div className="w-combobox__option-text">
{item.render
? item.render({ option: item })
: description}
</div>
return (
<div className="w-combobox__optgroup" key={category.type}>
{category.label ? (
<div className="w-combobox__optgroup-label">
{category.label}
</div>
);
})}
</div>
);
})}
) : null}
{categoryItems.map((item, index) => {
const itemLabel = getItemLabel(item.type, item);
const description = getItemDescription(item);
const itemIndex = inputItems.findIndex(
(i) => i.type === item.type,
);
const itemColumn = index + 1 <= itemColumns ? 1 : 2;
const hasIcon =
typeof item.icon !== 'undefined' && item.icon !== null;
let icon: JSX.Element | null | undefined = null;
if (hasIcon) {
if (Array.isArray(item.icon)) {
icon = (
<Icon name="custom" viewBox="0 0 1024 1024">
{item.icon.map((pathData: string) => (
<path key={pathData} d={pathData} />
))}
</Icon>
);
} else {
icon =
typeof item.icon === 'string' ? (
<Icon name={item.icon} />
) : (
item.icon
);
}
}
return (
<div
key={item.type}
{...getItemProps({ item, index: itemIndex })}
className={`w-combobox__option w-combobox__option--col${itemColumn}`}
>
<div className="w-combobox__option-icon">
{icon}
{/* Support for rich text options using text as an icon (for example "B" for bold). */}
{itemLabel && !hasIcon ? (
<span>{itemLabel}</span>
) : null}
</div>
<div className="w-combobox__option-text">
{item.render
? item.render({ option: item })
: description}
</div>
</div>
);
})}
</div>
);
})}
</div>
</div>
{selectedBlock ? <ComboBoxPreview item={selectedBlock} /> : null}
</div>
);
}

Wyświetl plik

@ -2,182 +2,186 @@
exports[`ComboBox rendering matches the snapshot 1`] = `
<div
className="w-combobox"
className="w-combobox-container"
>
<label
className="w-sr-only"
htmlFor="downshift-1-input"
id="downshift-1-label"
>
Search options…
</label>
<div
className="w-combobox__field"
>
<input
aria-activedescendant=""
aria-autocomplete="list"
aria-controls="downshift-1-menu"
aria-expanded={false}
aria-labelledby="downshift-1-label"
autoComplete="off"
disabled={false}
id="downshift-1-input"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
onKeyDown={[Function]}
placeholder="Search options…"
role="combobox"
type="text"
value=""
/>
</div>
<div
aria-labelledby="downshift-1-label"
className="w-combobox__menu"
id="downshift-1-menu"
onMouseLeave={[Function]}
role="listbox"
className="w-combobox"
>
<label
className="w-sr-only"
htmlFor="downshift-1-input"
id="downshift-1-label"
>
Search options…
</label>
<div
className="w-combobox__optgroup"
key="blockTypes"
className="w-combobox__field"
>
<input
aria-activedescendant=""
aria-autocomplete="list"
aria-controls="downshift-1-menu"
aria-expanded={false}
aria-labelledby="downshift-1-label"
autoComplete="off"
disabled={false}
id="downshift-1-input"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
onKeyDown={[Function]}
placeholder="Search options…"
role="combobox"
type="text"
value=""
/>
</div>
<div
aria-labelledby="downshift-1-label"
className="w-combobox__menu"
id="downshift-1-menu"
onMouseLeave={[Function]}
role="listbox"
>
<div
className="w-combobox__optgroup-label"
>
Blocks
</div>
<div
aria-selected="false"
className="w-combobox__option w-combobox__option--col1"
id="downshift-1-item-0"
key="blockquote"
onClick={[Function]}
onMouseDown={[Function]}
onMouseMove={[Function]}
role="option"
className="w-combobox__optgroup"
key="blockTypes"
>
<div
className="w-combobox__option-icon"
className="w-combobox__optgroup-label"
>
<Icon
name="blockquote"
/>
Blocks
</div>
<div
className="w-combobox__option-text"
aria-selected="false"
className="w-combobox__option w-combobox__option--col1"
id="downshift-1-item-0"
key="blockquote"
onClick={[Function]}
onMouseDown={[Function]}
onMouseMove={[Function]}
role="option"
>
Blockquote
</div>
</div>
<div
aria-selected="false"
className="w-combobox__option w-combobox__option--col1"
id="downshift-1-item-1"
key="paragraph"
onClick={[Function]}
onMouseDown={[Function]}
onMouseMove={[Function]}
role="option"
>
<div
className="w-combobox__option-icon"
>
<span
className="my-icon"
<div
className="w-combobox__option-icon"
>
P
</span>
</div>
<div
className="w-combobox__option-text"
>
Paragraph
</div>
</div>
<div
aria-selected="false"
className="w-combobox__option w-combobox__option--col1"
id="downshift-1-item-2"
key="heading-one"
onClick={[Function]}
onMouseDown={[Function]}
onMouseMove={[Function]}
role="option"
>
<div
className="w-combobox__option-icon"
>
<Icon
name="custom"
viewBox="0 0 1024 1024"
>
<path
d="M 83.625 "
key="M 83.625 "
<Icon
name="blockquote"
/>
<path
d="L 232.535156 "
key="L 232.535156 "
/>
</Icon>
</div>
<div
className="w-combobox__option-text"
>
Heading 1
</div>
</div>
<div
aria-selected="false"
className="w-combobox__option w-combobox__option--col2"
id="downshift-1-item-3"
key="heading-two"
onClick={[Function]}
onMouseDown={[Function]}
onMouseMove={[Function]}
role="option"
>
<div
className="w-combobox__option-icon"
>
<span>
H2
</span>
</div>
<div
className="w-combobox__option-text"
>
<span
className="custom-text"
</div>
<div
className="w-combobox__option-text"
>
H2
</span>
</div>
</div>
<div
aria-selected="false"
className="w-combobox__option w-combobox__option--col2"
id="downshift-1-item-4"
key="link"
onClick={[Function]}
onMouseDown={[Function]}
onMouseMove={[Function]}
role="option"
>
<div
className="w-combobox__option-icon"
>
<span>
🔗
</span>
Blockquote
</div>
</div>
<div
className="w-combobox__option-text"
aria-selected="false"
className="w-combobox__option w-combobox__option--col1"
id="downshift-1-item-1"
key="paragraph"
onClick={[Function]}
onMouseDown={[Function]}
onMouseMove={[Function]}
role="option"
>
Link
<div
className="w-combobox__option-icon"
>
<span
className="my-icon"
>
P
</span>
</div>
<div
className="w-combobox__option-text"
>
Paragraph
</div>
</div>
<div
aria-selected="false"
className="w-combobox__option w-combobox__option--col1"
id="downshift-1-item-2"
key="heading-one"
onClick={[Function]}
onMouseDown={[Function]}
onMouseMove={[Function]}
role="option"
>
<div
className="w-combobox__option-icon"
>
<Icon
name="custom"
viewBox="0 0 1024 1024"
>
<path
d="M 83.625 "
key="M 83.625 "
/>
<path
d="L 232.535156 "
key="L 232.535156 "
/>
</Icon>
</div>
<div
className="w-combobox__option-text"
>
Heading 1
</div>
</div>
<div
aria-selected="false"
className="w-combobox__option w-combobox__option--col2"
id="downshift-1-item-3"
key="heading-two"
onClick={[Function]}
onMouseDown={[Function]}
onMouseMove={[Function]}
role="option"
>
<div
className="w-combobox__option-icon"
>
<span>
H2
</span>
</div>
<div
className="w-combobox__option-text"
>
<span
className="custom-text"
>
H2
</span>
</div>
</div>
<div
aria-selected="false"
className="w-combobox__option w-combobox__option--col2"
id="downshift-1-item-4"
key="link"
onClick={[Function]}
onMouseDown={[Function]}
onMouseMove={[Function]}
role="option"
>
<div
className="w-combobox__option-icon"
>
<span>
🔗
</span>
</div>
<div
className="w-combobox__option-text"
>
Link
</div>
</div>
</div>
</div>

Wyświetl plik

@ -0,0 +1,41 @@
.w-combobox-preview {
padding: theme('spacing.5');
display: grid;
grid-template-rows: 6fr 4fr;
gap: theme('spacing.5');
background-color: theme('colors.surface-header');
border-block-start: 1px solid theme('colors.border-furniture');
border-end-end-radius: inherit;
border-end-start-radius: inherit;
@include media-breakpoint-up(sm) {
border-block-start: 0;
border-start-end-radius: inherit;
border-end-start-radius: 0;
border-inline-start: 1px solid theme('colors.border-furniture');
}
}
.w-combobox-preview__iframe {
width: 100%;
height: 100%;
border: 1px solid theme('colors.border-furniture');
border-radius: theme('borderRadius.sm');
// Ensure iframe is always opaque
color-scheme: normal;
background-color: Canvas;
@include more-contrast() {
border-color: theme('colors.border-furniture-more-contrast');
}
}
.w-combobox-preview__label {
@apply w-label-1;
}
.w-combobox-preview__description {
@apply w-help-text;
margin-top: theme('spacing.3');
margin-bottom: 0;
}

Wyświetl plik

@ -0,0 +1,35 @@
import React from 'react';
import { WAGTAIL_CONFIG } from '../../config/wagtailConfig';
interface ComboBoxItem {
label?: string | null;
description?: string | null;
// icon?: string | JSX.Element | null;
blockDefId?: string;
}
export interface ComboBoxPreviewProps {
item: ComboBoxItem;
}
export default function ComboBoxPreview({
item: { label, description, blockDefId },
}: ComboBoxPreviewProps) {
const previewURL = blockDefId
? new URL(WAGTAIL_CONFIG.ADMIN_URLS.BLOCK_PREVIEW, window.location.href)
: undefined;
previewURL?.searchParams.append('id', blockDefId || '');
return (
<div className="w-combobox-preview">
<iframe
className="w-combobox-preview__iframe"
title="Preview"
src={previewURL?.toString()}
/>
<div className="w-combobox-preview__label">{label}</div>
{description ? (
<p className="w-combobox-preview__description">{description}</p>
) : null}
</div>
);
}

Wyświetl plik

@ -104,6 +104,7 @@ class StreamBlockMenu extends BaseInsertionControl {
content: this.combobox,
trigger: 'click',
interactive: true,
maxWidth: 'none',
theme: 'dropdown',
arrow: false,
placement: 'bottom',
@ -120,7 +121,9 @@ class StreamBlockMenu extends BaseInsertionControl {
const groupItems = blockDefs.map((blockDef) => ({
type: blockDef.name,
label: blockDef.meta.label,
description: blockDef.meta.description,
icon: blockDef.meta.icon,
blockDefId: blockDef.meta.blockDefId,
}));
return {

Wyświetl plik

@ -751,7 +751,7 @@ exports[`telepath: wagtail.blocks.StreamBlock it renders menus on opening 1`] =
<button type="button" title="Insert a block" class="c-sf-add-button" aria-expanded="true">
<svg class="icon icon-plus" aria-hidden="true"><use href="#icon-plus"></use></svg>
</button>
<div data-tippy-root="" id="tippy-5" style="z-index: 9999; visibility: visible; transition: none; position: absolute; left: 0px; top: 0px; margin: 0px;"><div class="tippy-box" data-state="hidden" tabindex="-1" data-theme="dropdown" data-animation="fade" style="max-width: 350px; transition-duration: 0ms;" role="tooltip"><div class="tippy-content" data-state="hidden" style="transition-duration: 0ms;"><div><div class="w-combobox"><label id="downshift-0-label" for="downshift-0-input" class="w-sr-only">Search options…</label><div class="w-combobox__field"><input aria-activedescendant="" aria-autocomplete="list" aria-controls="downshift-0-menu" aria-expanded="false" aria-labelledby="downshift-0-label" autocomplete="off" id="downshift-0-input" role="combobox" type="text" placeholder="Search options…" value=""></div><div id="downshift-0-menu" role="listbox" aria-labelledby="downshift-0-label" class="w-combobox__menu"><div class="w-combobox__optgroup"><div role="option" aria-selected="false" id="downshift-0-item-0" class="w-combobox__option w-combobox__option--col1"><div class="w-combobox__option-icon"><svg class="icon icon-placeholder" aria-hidden="true"><use href="#icon-placeholder"></use></svg></div><div class="w-combobox__option-text">Test Block A</div></div><div role="option" aria-selected="false" id="downshift-0-item-1" class="w-combobox__option w-combobox__option--col2"><div class="w-combobox__option-icon"><svg class="icon icon-pilcrow" aria-hidden="true"><use href="#icon-pilcrow"></use></svg></div><div class="w-combobox__option-text">Test Block B</div></div></div></div></div></div></div></div></div></div><div data-streamfield-child="" data-contentpath="2">
<div data-tippy-root="" id="tippy-5" style="z-index: 9999; visibility: visible; transition: none; position: absolute; left: 0px; top: 0px; margin: 0px;"><div class="tippy-box" data-state="hidden" tabindex="-1" data-theme="dropdown" data-animation="fade" style="max-width: none; transition-duration: 0ms;" role="tooltip"><div class="tippy-content" data-state="hidden" style="transition-duration: 0ms;"><div><div class="w-combobox-container"><div class="w-combobox"><label id="downshift-0-label" for="downshift-0-input" class="w-sr-only">Search options…</label><div class="w-combobox__field"><input aria-activedescendant="" aria-autocomplete="list" aria-controls="downshift-0-menu" aria-expanded="false" aria-labelledby="downshift-0-label" autocomplete="off" id="downshift-0-input" role="combobox" type="text" placeholder="Search options…" value=""></div><div id="downshift-0-menu" role="listbox" aria-labelledby="downshift-0-label" class="w-combobox__menu"><div class="w-combobox__optgroup"><div role="option" aria-selected="false" id="downshift-0-item-0" class="w-combobox__option w-combobox__option--col1"><div class="w-combobox__option-icon"><svg class="icon icon-placeholder" aria-hidden="true"><use href="#icon-placeholder"></use></svg></div><div class="w-combobox__option-text">Test Block A</div></div><div role="option" aria-selected="false" id="downshift-0-item-1" class="w-combobox__option w-combobox__option--col2"><div class="w-combobox__option-icon"><svg class="icon icon-pilcrow" aria-hidden="true"><use href="#icon-pilcrow"></use></svg></div><div class="w-combobox__option-text">Test Block B</div></div></div></div></div></div></div></div></div></div></div><div data-streamfield-child="" data-contentpath="2">
<input type="hidden" name="the-prefix-1-deleted" value="">
<input type="hidden" name="the-prefix-1-order" value="1">
<input type="hidden" name="the-prefix-1-type" value="test_block_b">

Wyświetl plik

@ -8,6 +8,7 @@ export interface WagtailConfig {
ADMIN_URLS: {
DISMISSIBLES: string;
PAGES: string;
BLOCK_PREVIEW: string;
};
CSRF_HEADER_NAME: string;
CSRF_TOKEN: string;

Wyświetl plik

@ -983,6 +983,7 @@ def wagtail_config(context):
"ADMIN_URLS": {
"DISMISSIBLES": reverse("wagtailadmin_dismissibles"),
"PAGES": reverse("wagtailadmin_explore_root"),
"BLOCK_PREVIEW": reverse("wagtailadmin_block_preview"),
},
"I18N_ENABLED": i18n_enabled(),
"LOCALES": locales(serialize=False),