More adaptable button interface

This commit is contained in:
Benjamin Bädorf 2020-05-28 22:16:35 +02:00
parent 4948cf9c3b
commit 191778b440
No known key found for this signature in database
GPG key ID: 4406E80E13CD656C
15 changed files with 540 additions and 57 deletions

26
package-lock.json generated
View file

@ -1461,6 +1461,21 @@
"integrity": "sha512-8+KAKzEvSUdeo+kmqnKrqgeE+LcA0tjYWFY7RPProVYwnqDjukzO+3b6dLD56rYX5TdWejnEOLJYOIeh4CXKuA==",
"dev": true
},
"@types/lodash": {
"version": "4.14.153",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.153.tgz",
"integrity": "sha512-lYniGRiRfZf2gGAR9cfRC3Pi5+Q1ziJCKqPmjZocigrSJUVPWf7st1BtSJ8JOeK0FLXVndQ1IjUjTco9CXGo/Q==",
"dev": true
},
"@types/lodash-es": {
"version": "4.17.3",
"resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.3.tgz",
"integrity": "sha512-iHI0i7ZAL1qepz1Y7f3EKg/zUMDwDfTzitx+AlHhJJvXwenP682ZyGbgPSc5Ej3eEAKVbNWKFuwOadCj5vBbYQ==",
"dev": true,
"requires": {
"@types/lodash": "*"
}
},
"@types/minimatch": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.3.tgz",
@ -9944,6 +9959,11 @@
"integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==",
"dev": true
},
"lodash-es": {
"version": "4.17.15",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.15.tgz",
"integrity": "sha512-rlrc3yU3+JNOpZ9zj5pQtxnx2THmvRykwL4Xlxoa8I9lHBlVbbyPhgyPMioxVZ4NqyxaVVtaJnzsyOidQIhyyQ=="
},
"lodash._arraycopy": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/lodash._arraycopy/-/lodash._arraycopy-3.0.0.tgz",
@ -14887,9 +14907,9 @@
"dev": true
},
"typescript": {
"version": "3.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-3.8.3.tgz",
"integrity": "sha512-MYlEfn5VrLNsgudQTVJeNaQFUAI7DkhnOjdpAp4T+ku1TfQClewlbSuTVHiA+8skNBgaf02TL/kLOvig4y3G8w==",
"version": "3.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-3.9.3.tgz",
"integrity": "sha512-D/wqnB2xzNFIcoBG9FG8cXRDjiqSTbG2wd8DMZeQyJlP1vfTkIxH4GKveWaEBYySKIg+USu+E+EDIR47SqnaMQ==",
"dev": true
},
"uglify-js": {

View file

@ -12,10 +12,12 @@
"dependencies": {
"@vue/composition-api": "^0.5.0",
"core-js": "^3.6.4",
"lodash-es": "^4.17.15",
"vue": "^2.6.11"
},
"devDependencies": {
"@types/jest": "^24.0.19",
"@types/lodash-es": "^4.17.3",
"@typescript-eslint/eslint-plugin": "^2.26.0",
"@typescript-eslint/parser": "^2.26.0",
"@vue/cli-plugin-babel": "~4.3.0",
@ -35,7 +37,7 @@
"geckodriver": "^1.19.1",
"sass": "^1.26.3",
"sass-loader": "^8.0.2",
"typescript": "~3.8.3",
"typescript": "~3.9.3",
"vue-template-compiler": "^2.6.11",
"vuex": "^3.4.0"
},

242
src/ResizeObserver.d.ts vendored Normal file
View file

@ -0,0 +1,242 @@
/**
* The **ResizeObserver** interface reports changes to the dimensions of an
* [Element](https://developer.mozilla.org/en-US/docs/Web/API/Element)'s content
* or border box, or the bounding box of an
* [SVGElement](https://developer.mozilla.org/en-US/docs/Web/API/SVGElement).
*
* > **Note**: The content box is the box in which content can be placed,
* > meaning the border box minus the padding and border width. The border box
* > encompasses the content, padding, and border. See
* > [The box model](https://developer.mozilla.org/en-US/docs/Learn/CSS/Building_blocks/The_box_model)
* > for further explanation.
*
* `ResizeObserver` avoids infinite callback loops and cyclic dependencies that
* are often created when resizing via a callback function. It does this by only
* processing elements deeper in the DOM in subsequent frames. Implementations
* should, if they follow the specification, invoke resize events before paint
* and after layout.
*
* @see https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver
*/
declare class ResizeObserver {
/**
* The **ResizeObserver** constructor creates a new `ResizeObserver` object,
* which can be used to report changes to the content or border box of an
* `Element` or the bounding box of an `SVGElement`.
*
* @example
* var ResizeObserver = new ResizeObserver(callback)
*
* @param callback
* The function called whenever an observed resize occurs. The function is
* called with two parameters:
* * **entries**
* An array of
* [ResizeObserverEntry](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserverEntry)
* objects that can be used to access the new dimensions of the element
* after each change.
* * **observer**
* A reference to the `ResizeObserver` itself, so it will definitely be
* accessible from inside the callback, should you need it. This could be
* used for example to automatically unobserve the observer when a certain
* condition is reached, but you can omit it if you don't need it.
*
* The callback will generally follow a pattern along the lines of:
* ```js
* function(entries, observer) {
* for (let entry of entries) {
* // Do something to each entry
* // and possibly something to the observer itself
* }
* }
* ```
*
* The following snippet is taken from the
* [resize-observer-text.html](https://mdn.github.io/dom-examples/resize-observer/resize-observer-text.html)
* ([see source](https://github.com/mdn/dom-examples/blob/master/resize-observer/resize-observer-text.html))
* example:
* @example
* const resizeObserver = new ResizeObserver(entries => {
* for (let entry of entries) {
* if(entry.contentBoxSize) {
* h1Elem.style.fontSize = Math.max(1.5, entry.contentBoxSize.inlineSize/200) + 'rem';
* pElem.style.fontSize = Math.max(1, entry.contentBoxSize.inlineSize/600) + 'rem';
* } else {
* h1Elem.style.fontSize = Math.max(1.5, entry.contentRect.width/200) + 'rem';
* pElem.style.fontSize = Math.max(1, entry.contentRect.width/600) + 'rem';
* }
* }
* });
*
* resizeObserver.observe(divElem);
*/
constructor(callback: ResizeObserverCallback);
/**
* The **disconnect()** method of the
* [ResizeObserver](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver)
* interface unobserves all observed
* [Element](https://developer.mozilla.org/en-US/docs/Web/API/Element) or
* [SVGElement](https://developer.mozilla.org/en-US/docs/Web/API/SVGElement)
* targets.
*/
disconnect: () => void;
/**
* The `observe()` method of the
* [ResizeObserver](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver)
* interface starts observing the specified
* [Element](https://developer.mozilla.org/en-US/docs/Web/API/Element) or
* [SVGElement](https://developer.mozilla.org/en-US/docs/Web/API/SVGElement).
*
* @example
* resizeObserver.observe(target, options);
*
* @param target
* A reference to an
* [Element](https://developer.mozilla.org/en-US/docs/Web/API/Element) or
* [SVGElement](https://developer.mozilla.org/en-US/docs/Web/API/SVGElement)
* to be observed.
*
* @param options
* An options object allowing you to set options for the observation.
* Currently this only has one possible option that can be set.
*/
observe: (target: Element, options?: ResizeObserverObserveOptions) => void;
/**
* The **unobserve()** method of the
* [ResizeObserver](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver)
* interface ends the observing of a specified
* [Element](https://developer.mozilla.org/en-US/docs/Web/API/Element) or
* [SVGElement](https://developer.mozilla.org/en-US/docs/Web/API/SVGElement).
*/
unobserve: (target: Element) => void;
}
interface ResizeObserverObserveOptions {
/**
* Sets which box model the observer will observe changes to. Possible values
* are `content-box` (the default), and `border-box`.
*
* @default "content-box"
*/
box?: "content-box" | "border-box";
}
/**
* The function called whenever an observed resize occurs. The function is
* called with two parameters:
*
* @param entries
* An array of
* [ResizeObserverEntry](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserverEntry)
* objects that can be used to access the new dimensions of the element after
* each change.
*
* @param observer
* A reference to the `ResizeObserver` itself, so it will definitely be
* accessible from inside the callback, should you need it. This could be used
* for example to automatically unobserve the observer when a certain condition
* is reached, but you can omit it if you don't need it.
*
* The callback will generally follow a pattern along the lines of:
* @example
* function(entries, observer) {
* for (let entry of entries) {
* // Do something to each entry
* // and possibly something to the observer itself
* }
* }
*
* @example
* const resizeObserver = new ResizeObserver(entries => {
* for (let entry of entries) {
* if(entry.contentBoxSize) {
* h1Elem.style.fontSize = Math.max(1.5, entry.contentBoxSize.inlineSize/200) + 'rem';
* pElem.style.fontSize = Math.max(1, entry.contentBoxSize.inlineSize/600) + 'rem';
* } else {
* h1Elem.style.fontSize = Math.max(1.5, entry.contentRect.width/200) + 'rem';
* pElem.style.fontSize = Math.max(1, entry.contentRect.width/600) + 'rem';
* }
* }
* });
*
* resizeObserver.observe(divElem);
*/
type ResizeObserverCallback = (
entries: ResizeObserverEntry[],
observer: ResizeObserver,
) => void;
/**
* The **ResizeObserverEntry** interface represents the object passed to the
* [ResizeObserver()](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver/ResizeObserver)
* constructor's callback function, which allows you to access the new
* dimensions of the
* [Element](https://developer.mozilla.org/en-US/docs/Web/API/Element) or
* [SVGElement](https://developer.mozilla.org/en-US/docs/Web/API/SVGElement)
* being observed.
*/
interface ResizeObserverEntry {
/**
* An object containing the new border box size of the observed element when
* the callback is run.
*/
readonly borderBoxSize: ResizeObserverEntryBoxSize;
/**
* An object containing the new content box size of the observed element when
* the callback is run.
*/
readonly contentBoxSize: ResizeObserverEntryBoxSize;
/**
* A [DOMRectReadOnly](https://developer.mozilla.org/en-US/docs/Web/API/DOMRectReadOnly)
* object containing the new size of the observed element when the callback is
* run. Note that this is better supported than the above two properties, but
* it is left over from an earlier implementation of the Resize Observer API,
* is still included in the spec for web compat reasons, and may be deprecated
* in future versions.
*/
// node_modules/typescript/lib/lib.dom.d.ts
readonly contentRect: DOMRectReadOnly;
/**
* A reference to the
* [Element](https://developer.mozilla.org/en-US/docs/Web/API/Element) or
* [SVGElement](https://developer.mozilla.org/en-US/docs/Web/API/SVGElement)
* being observed.
*/
readonly target: Element;
}
/**
* The **borderBoxSize** read-only property of the
* [ResizeObserverEntry](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserverEntry)
* interface returns an object containing the new border box size of the
* observed element when the callback is run.
*/
interface ResizeObserverEntryBoxSize {
/**
* The length of the observed element's border box in the block dimension. For
* boxes with a horizontal
* [writing-mode](https://developer.mozilla.org/en-US/docs/Web/CSS/writing-mode),
* this is the vertical dimension, or height; if the writing-mode is vertical,
* this is the horizontal dimension, or width.
*/
blockSize: number;
/**
* The length of the observed element's border box in the inline dimension.
* For boxes with a horizontal
* [writing-mode](https://developer.mozilla.org/en-US/docs/Web/CSS/writing-mode),
* this is the horizontal dimension, or width; if the writing-mode is
* vertical, this is the vertical dimension, or height.
*/
inlineSize: number;
}
interface Window {
ResizeObserver: typeof ResizeObserver;
}

View file

@ -1,5 +1,5 @@
.sb-main {
padding: 50px 20px;
position: relative;
background-color: var(--bg);
padding: 50px 40px;
}

View file

@ -4,6 +4,8 @@ import {
reactive,
ref,
PropType,
Ref,
watch,
} from '@vue/composition-api';
import {
model,
@ -11,9 +13,11 @@ import {
Block,
SbMode,
Mode,
EditorDimensions,
BlockDefinition,
BlockLibraryDefinition,
BlockLibrary,
useResizeObserver,
} from '@components/TreeElement';
import SbBlock from '@internal/Block';
@ -51,6 +55,9 @@ export default defineComponent({
},
setup(props: SchlechtenburgProps) {
const el: Ref<null|HTMLElement> = ref(null);
useResizeObserver(el, EditorDimensions);
const mode = ref(props.mode);
provide(Mode, mode);
@ -73,7 +80,10 @@ export default defineComponent({
provide(BlockLibrary, blockLibrary);
return () => (
<div class="sb-main">
<div
class="sb-main"
ref={el}
>
<SbBlock
block={props.block}
eventUpdate={props.eventUpdate}

View file

@ -4,6 +4,8 @@ import {
inject,
reactive,
computed,
watch,
provide,
} from '@vue/composition-api';
export interface BlockDefinition {
@ -58,6 +60,53 @@ export function useDynamicBlocks() {
};
}
interface BlockRect {
height: number;
width: number;
left: number;
top: number;
}
export const BlockDimensions = Symbol('Schlechtenburg block dimensions');
export const EditorDimensions = Symbol('Schlechtenburg editor dimensions');
export function useResizeObserver(el: Ref<null|HTMLElement>, symbol: symbol) {
const dimensions: Ref<null|BlockRect> = ref(null);
provide(symbol, dimensions);
const triggerSizeCalculation = () => {
if (!el.value) {
return;
}
const clientRect = el.value.getBoundingClientRect();
dimensions.value = {
width: clientRect.width,
height: clientRect.height,
left: el.value.offsetLeft,
top: el.value.offsetTop,
};
};
const resizeObserver = new ResizeObserver(triggerSizeCalculation);
const mutationObserver = new MutationObserver(triggerSizeCalculation);
watch(el, () => {
if (!el.value) {
return;
}
resizeObserver.observe(el.value);
mutationObserver.observe(el.value, { attributes: true, childList: false, subtree: false });
});
return { triggerSizeCalculation, dimensions };
}
export function useBlockSizing() {
const editorDimensions: Ref<BlockRect|null> = inject(EditorDimensions, ref(null));
const blockDimensions: Ref<BlockRect|null> = inject(BlockDimensions, ref(null));
return { editorDimensions, blockDimensions };
}
export const ActiveBlock = Symbol('Schlechtenburg active block');
export function useActivation(currentBlockId: string) {
const activeBlockId: Ref<string|null> = inject(ActiveBlock, ref(null));

View file

@ -1,5 +1,6 @@
.sb-block {
position: relative;
$block: &;
display: flex;
align-items: stretch;
justify-items: stretch;
@ -10,6 +11,14 @@
pointer-events: none;
}
&__edit-cover {
}
> .sb-block-ordering {
opacity: 0;
pointer-events: none;
}
&_active {
outline: 1px solid var(--grey-2);
@ -18,25 +27,10 @@
pointer-events: all;
outline: 1px solid var(--grey-2);
}
}
&__edit-cover {
}
&__remove {
position: absolute;
left: 100%;
top: 0;
max-height: 100%;
height: auto;
width: auto;
}
&__sliders {
position: absolute;
right: 100%;
width: auto;
max-width: 100%;
height: auto;
> .sb-block-ordering {
opacity: 1;
pointer-events: all;
}
}
}

View file

@ -1,16 +1,21 @@
import {
computed,
defineComponent,
watch,
PropType,
ref,
Ref,
} from '@vue/composition-api';
import {
Block,
useDynamicBlocks,
useActivation,
SbMode,
BlockDimensions,
useResizeObserver,
} from '@components/TreeElement';
import SbButton from './Button';
import SbBlockOrdering from './BlockOrdering';
import './Block.scss';
@ -20,6 +25,9 @@ interface BlockProps {
eventInsertBlock: (b?: Block) => void;
eventAppendBlock: (b?: Block) => void;
eventRemoveBlock: () => void;
eventMoveUp: () => void;
eventMoveDown: () => void;
sortable: string;
}
export default defineComponent({
@ -30,30 +38,27 @@ export default defineComponent({
type: (null as unknown) as PropType<Block>,
required: true,
},
sortable: {
type: String,
default: null,
},
eventUpdate: { type: Function, default: () => {} },
eventInsertBlock: { type: Function, default: () => {} },
eventAppendBlock: { type: Function, default: () => {} },
eventRemoveBlock: { type: Function, default: () => {} },
eventMoveUp: { type: Function, default: () => {} },
eventMoveDown: { type: Function, default: () => {} },
},
setup(props: BlockProps, context) {
const { isActive, activate } = useActivation(props.block.blockId);
const el: Ref<null|HTMLElement> = ref(null);
const { mode, getBlock } = useDynamicBlocks();
const { isActive, activate } = useActivation(props.block.blockId);
const classes = computed(() => ({
'sb-block': true,
'sb-block_active': isActive.value,
}));
const onChildUpdate = (updated: {[key: string]: any}) => {
props.eventUpdate({
...props.block,
data: {
...props.block.data,
...updated,
},
});
};
const BlockComponent = getBlock(props.block.name) as any;
if (mode.value === SbMode.Display) {
return () => (
@ -64,15 +69,33 @@ export default defineComponent({
);
}
return () => (<div class={classes.value}>
const { triggerSizeCalculation } = useResizeObserver(el, BlockDimensions);
watch(() => props.block.data, triggerSizeCalculation);
const onChildUpdate = (updated: {[key: string]: any}) => {
props.eventUpdate({
...props.block,
data: {
...props.block.data,
...updated,
},
});
};
return () => <div
ref={el}
class={classes.value}
onClick={($event: MouseEvent) => $event.stopPropagation()}
>
<div class="sb-block__edit-cover"></div>
<div class="sb-block__remove">
<SbButton>x</SbButton>
</div>
<div class="sb-block__sliders">
<SbButton>&gt;</SbButton>
<SbButton>&lt;</SbButton>
</div>
{props.sortable
? <SbBlockOrdering
eventMoveUp={props.eventMoveUp}
eventMoveDown={props.eventMoveDown}
eventRemoveBlock={props.eventRemoveBlock}
sortable={props.sortable}
/>
: null}
<BlockComponent
data={props.block.data}
block-id={props.block.blockId}
@ -91,6 +114,6 @@ export default defineComponent({
},
}}
/>
</div>);
</div>;
},
});

View file

@ -0,0 +1,5 @@
.sb-block-ordering {
display: flex;
position: absolute;
flex-direction: column;
}

View file

@ -0,0 +1,65 @@
import debounce from 'lodash-es/debounce';
import {
defineComponent,
watch,
reactive,
computed,
} from '@vue/composition-api';
import {
useBlockSizing,
} from '@components/TreeElement';
import SbButton from './Button';
import './BlockOrdering.scss';
export default defineComponent({
name: 'sb-block-ordering',
props: {
sortable: {
type: String,
default: null,
},
eventRemoveBlock: { type: Function, default: () => {} },
eventMoveUp: { type: Function, default: () => {} },
eventMoveDown: { type: Function, default: () => {} },
},
setup(props) {
const styles = reactive({
top: '',
right: '',
});
const classes = computed(() => ({
'sb-block-ordering': true,
[`sb-block-ordering_${props.sortable}`]: !!props.sortable,
}));
const { editorDimensions, blockDimensions } = useBlockSizing();
const resetStyles = debounce(() => {
if (!editorDimensions.value || !blockDimensions.value) {
return;
}
const right = editorDimensions.value.width - blockDimensions.value.left;
styles.top = `${blockDimensions.value.top}px`;
styles.right = `${right}px`;
});
watch(editorDimensions, resetStyles);
watch(blockDimensions, resetStyles);
watch(() => props.sortable, resetStyles);
return () => (
<div
class={classes.value}
style={styles}
>
<SbButton onClick={props.eventMoveUp}>{props.sortable === 'vertical' ? '↑' : '←'}</SbButton>
<SbButton onClick={props.eventRemoveBlock}>x</SbButton>
<SbButton onClick={props.eventMoveDown}>{props.sortable === 'vertical' ? '↓' : '→'}</SbButton>
</div>
);
},
});

View file

@ -1,8 +1,5 @@
.sb-toolbar {
position: absolute;
bottom: 100%;
left: 0;
width: auto;
max-width: 100%;
height: auto;
}

View file

@ -1,4 +1,10 @@
import { defineComponent } from '@vue/composition-api';
import debounce from 'lodash-es/debounce';
import {
defineComponent,
watch,
reactive,
} from '@vue/composition-api';
import { useBlockSizing } from '@components/TreeElement';
import './Toolbar.scss';
@ -6,8 +12,31 @@ export default defineComponent({
name: 'sb-toolbar',
setup(props, context) {
const styles = reactive({
bottom: '',
left: '',
maxWidth: '',
});
const { editorDimensions, blockDimensions } = useBlockSizing();
const resetStyles = debounce(() => {
if (!editorDimensions.value || !blockDimensions.value) {
return;
}
const bottom = editorDimensions.value.height - blockDimensions.value.top;
styles.bottom = `${bottom}px`;
styles.left = `${blockDimensions.value.left}px`;
styles.maxWidth = `${blockDimensions.value.width}px`;
});
watch(editorDimensions, resetStyles);
watch(blockDimensions, resetStyles);
return () => (
<div class="sb-toolbar">
<div
class="sb-toolbar"
style={styles}
>
{context.slots.default()}
</div>
);

View file

@ -110,6 +110,40 @@ export default defineComponent({
activate(localData.children[newActiveIndex].blockId);
};
const moveUp = (index: number) => {
if (index === 0) {
return;
}
const curr = localData.children[index];
const prev = localData.children[index - 1];
localData.children = [
...localData.children.slice(0, index - 1),
curr,
prev,
...localData.children.slice(index + 1),
];
props.eventUpdate({ children: [...localData.children] });
};
const moveDown = (index: number) => {
if (index === localData.children.length - 1) {
return;
}
const curr = localData.children[index];
const next = localData.children[index + 1];
localData.children = [
...localData.children.slice(0, index),
next,
curr,
...localData.children.slice(index + 2),
];
props.eventUpdate({ children: [...localData.children] });
};
return () => (
<div class={classes.value}>
<SbToolbar slot="toolbar">
@ -125,12 +159,17 @@ export default defineComponent({
{...localData.children.map((child, index) => (
<SbBlock
key={child.blockId}
{...{ key: child.blockId }}
data-order={index}
block={child}
eventUpdate={(updated: Block) => onChildUpdate(child, updated)}
eventInsertBlock={(block: Block) => insertBlock(index, block)}
eventAppendBlock={appendBlock}
eventRemoveBlock={() => removeBlock(index)}
eventMoveUp={() => moveUp(index)}
eventMoveDown={() => moveDown(index)}
sortable={localData.orientation}
removable
/>
))}

View file

@ -117,7 +117,7 @@ export default defineComponent({
activate(null);
};
const onKeyup = ($event: KeyboardEvent) => {
const onKeydown = ($event: KeyboardEvent) => {
if ($event.key === 'Enter' && !$event.shiftKey) {
const blockId = `${+(new Date())}`;
props.eventInsertBlock({
@ -129,7 +129,11 @@ export default defineComponent({
activate(blockId);
$event.preventDefault();
} else if ($event.key === 'Backspace' && localData.value === '') {
}
};
const onKeyup = ($event: KeyboardEvent) => {
if ($event.key === 'Backspace' && localData.value === '') {
props.eventRemoveBlock();
}
};
@ -153,6 +157,7 @@ export default defineComponent({
onInput={onTextUpdate}
onFocus={onFocus}
onBlur={onBlur}
onKeydown={onKeydown}
onKeyup={onKeyup}
></p>
</div>

View file

@ -1,4 +1,6 @@
* {
*,
*::before,
*::after {
box-sizing: border-box;
}
@ -24,4 +26,5 @@ html {
body {
margin: 0;
min-height: 100vh;
}