Format bar support APIs (#10063)

Introduce APIs that will be used by the Markdown formatting bar UI; use new APIs to simplify `ExtendedMenu`.

# Important Notes
New APIs:
- `DropdownMenu`: Encapsulates the dropdown pattern, i.e. a toggle button that opens a floating pane.
- `MenuButton`: A low-level control that adds button behavior to any element; all other buttons are now implemented using it.

Changes:
- `ToggleButton` and `SvgButton` now accept an optional `label` parameter.
This commit is contained in:
Kaz Wesley 2024-05-24 09:34:57 -07:00 committed by GitHub
parent a383819439
commit ac5fbbcd17
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 194 additions and 153 deletions

View File

@ -0,0 +1,81 @@
<script setup lang="ts">
import MenuButton from '@/components/MenuButton.vue'
import SizeTransition from '@/components/SizeTransition.vue'
import SvgIcon from '@/components/SvgIcon.vue'
import { injectInteractionHandler } from '@/providers/interactionHandler'
import { endOnClickOutside } from '@/util/autoBlur'
import { shift, useFloating, type Placement } from '@floating-ui/vue'
import { ref, shallowRef } from 'vue'
const open = defineModel<boolean>('open', { default: false })
const props = defineProps<{
placement?: Placement
}>()
const rootElement = shallowRef<HTMLElement>()
const floatElement = shallowRef<HTMLElement>()
const hovered = ref(false)
injectInteractionHandler().setWhen(
open,
endOnClickOutside(rootElement, {
cancel: () => (open.value = false),
end: () => (open.value = false),
}),
)
const { floatingStyles } = useFloating(rootElement, floatElement, {
placement: props.placement ?? 'bottom-start',
middleware: [shift()],
})
</script>
<template>
<div ref="rootElement" class="DropdownMenu" @pointerdown.prevent>
<MenuButton
v-model="open"
class="DropdownMenuButton"
@pointerenter="hovered = true"
@pointerleave="hovered = false"
>
<slot name="button" />
</MenuButton>
<SvgIcon v-if="hovered && !open" name="arrow_right_head_only" class="arrow" />
<SizeTransition height :duration="100">
<div v-if="open" ref="floatElement" class="DropdownMenuContent" :style="floatingStyles">
<slot name="entries" />
</div>
</SizeTransition>
</div>
</template>
<style scoped>
.DropdownMenu {
position: relative;
outline: 0;
margin: -4px;
}
.DropdownMenuContent {
display: flex;
flex-direction: column;
border-radius: 13px;
background: var(--color-frame-bg);
backdrop-filter: var(--blur-app-bg);
margin: 0 -4px;
z-index: 1;
gap: 4px;
padding: 8px;
}
.arrow {
position: absolute;
bottom: -5px;
left: 50%;
transform: translateX(-50%) rotate(90deg) scale(0.7);
transform-origin: center;
opacity: 0.5;
/* Prevent the parent from receiving a pointerout event if the mouse is over the arrow, which causes flickering. */
pointer-events: none;
}
</style>

View File

@ -1,10 +1,10 @@
<script setup lang="ts">
import { codeEditorBindings, documentationEditorBindings } from '@/bindings'
import DropdownMenu from '@/components/DropdownMenu.vue'
import MenuButton from '@/components/MenuButton.vue'
import SvgButton from '@/components/SvgButton.vue'
import SvgIcon from '@/components/SvgIcon.vue'
import { injectInteractionHandler, type Interaction } from '@/providers/interactionHandler'
import { targetIsOutside } from '@/util/autoBlur'
import { ref } from 'vue'
import SvgButton from './SvgButton.vue'
const showCodeEditor = defineModel<boolean>('showCodeEditor', { required: true })
const showDocumentationEditor = defineModel<boolean>('showDocumentationEditor', { required: true })
@ -17,48 +17,19 @@ const emit = defineEmits<{
fitToAllClicked: []
}>()
const buttonElement = ref<HTMLElement>()
const menuElement = ref<HTMLElement>()
const isDropdownOpen = ref(false)
const interaction = injectInteractionHandler()
const dropdownInteraction: Interaction = {
cancel: () => (isDropdownOpen.value = false),
end: () => (isDropdownOpen.value = false),
pointerdown: (e: PointerEvent) => {
if ([buttonElement.value, menuElement.value].every((area) => targetIsOutside(e, area)))
closeDropdown()
return false
},
}
function openDropdown() {
isDropdownOpen.value = true
interaction.setCurrent(dropdownInteraction)
}
function closeDropdown() {
interaction.cancel(dropdownInteraction)
}
function toggleDropdown() {
isDropdownOpen.value ? closeDropdown() : openDropdown()
}
const open = ref(false)
const toggleCodeEditorShortcut = codeEditorBindings.bindings.toggle.humanReadable
const toggleDocumentationEditorShortcut = documentationEditorBindings.bindings.toggle.humanReadable
</script>
<template>
<div
ref="buttonElement"
class="ExtendedMenu"
title="Additional Options"
@click.stop="toggleDropdown"
>
<SvgIcon name="3_dot_menu" class="moreIcon" />
</div>
<Transition name="dropdown">
<div v-show="isDropdownOpen" ref="menuElement" class="ExtendedMenuPane">
<div class="row">
<DropdownMenu v-model:open="open" placement="bottom-end" class="ExtendedMenu">
<template #button
><SvgIcon name="3_dot_menu" class="moreIcon" title="Additional Options"
/></template>
<template #entries>
<div>
<div class="label">Zoom</div>
<div class="zoomControl">
<SvgButton
@ -81,67 +52,45 @@ const toggleDocumentationEditorShortcut = documentationEditorBindings.bindings.t
/>
</div>
</div>
<div
class="row clickableRow"
:class="{ selected: showCodeEditor }"
@click="(showCodeEditor = !showCodeEditor), closeDropdown()"
>
<div class="label">Code Editor</div>
<MenuButton v-model="showCodeEditor" @click="open = false">
Code Editor
<div v-text="toggleCodeEditorShortcut" />
</div>
<div
class="row clickableRow"
:class="{ selected: showDocumentationEditor }"
@click="(showDocumentationEditor = !showDocumentationEditor), closeDropdown()"
>
<div class="label">Documentation Editor</div>
</MenuButton>
<MenuButton v-model="showDocumentationEditor" @click="open = false">
Documentation Editor
<div v-text="toggleDocumentationEditorShortcut" />
</div>
</div>
</Transition>
</MenuButton>
</template>
</DropdownMenu>
</template>
<style scoped>
.ExtendedMenu {
display: flex;
place-items: center;
gap: 12px;
width: 32px;
height: 32px;
margin-left: auto;
margin-right: 125px;
background: var(--color-frame-bg);
border-radius: var(--radius-full);
background: var(--color-frame-bg);
backdrop-filter: var(--blur-app-bg);
cursor: pointer;
margin: 0 125px 0 auto;
}
.ExtendedMenuPane {
position: fixed;
display: flex;
flex-direction: column;
.moreIcon {
margin: 4px;
}
.ExtendedMenu :deep(.DropdownMenuContent) {
width: 250px;
top: 40px;
margin-top: 6px;
margin-top: 2px;
padding: 4px;
right: 8px;
border-radius: 12px;
background: var(--color-frame-bg);
backdrop-filter: var(--blur-app-bg);
> * {
display: flex;
justify-content: space-between;
align-items: center;
padding-left: 8px;
padding-right: 8px;
}
}
.clickableRow {
cursor: pointer;
transition: background-color 0.3s;
&:hover {
background-color: var(--color-menu-entry-hover-bg);
}
&:active {
background-color: var(--color-menu-entry-active-bg);
}
&.selected {
background-color: var(--color-menu-entry-selected-bg);
}
.toggledOn {
background-color: var(--color-menu-entry-selected-bg);
}
.label {
@ -149,15 +98,6 @@ const toggleDocumentationEditorShortcut = documentationEditorBindings.bindings.t
pointer-events: none;
}
.row {
width: 100%;
display: flex;
padding: 0 8px 0 8px;
justify-content: space-between;
align-items: center;
border-radius: 12px;
}
.divider {
border-left: 1px solid var(--color-text);
border-right: 1px solid var(--color-text);
@ -177,23 +117,4 @@ const toggleDocumentationEditorShortcut = documentationEditorBindings.bindings.t
width: 4em;
text-align: center;
}
.moreIcon {
position: relative;
left: 8px;
}
.zoomButton {
--icon-transform: scale(12/16);
}
.dropdown-enter-active,
.dropdown-leave-active {
transition: opacity 0.25s ease;
}
.dropdown-enter-from,
.dropdown-leave-to {
opacity: 0;
}
</style>

View File

@ -0,0 +1,54 @@
<script setup lang="ts">
/**
* A button. Supports toggling and disabled state.
*
* If a boolean model is bound to the primary model, clicking the button will switch between `toggledOn` and
* `toggledOff` css classes on the slot's root element, as well as updating the model.
*
* If the disabled property is set, the button stops responding to mouse interaction and its contents will have the
* `disabled` class.
*/
const toggledOn = defineModel<boolean>({ default: undefined })
const props = defineProps<{ disabled?: boolean | undefined }>()
function onClick() {
if (!props.disabled && toggledOn.value != null) toggledOn.value = !toggledOn.value
}
</script>
<template>
<button
class="MenuButton button"
:class="{ toggledOn, toggledOff: toggledOn === false, disabled }"
:disabled="disabled ?? false"
@click.stop="onClick"
>
<slot />
</button>
</template>
<style scoped>
.MenuButton {
display: flex;
justify-items: center;
align-items: center;
min-width: max-content;
padding: 4px;
border-radius: var(--radius-full);
border: none;
cursor: pointer;
transition: background-color 0.3s;
&:hover {
background-color: var(--color-menu-entry-hover-bg);
}
&:active {
background-color: var(--color-menu-entry-active-bg);
}
&.disabled {
&:hover {
background-color: unset;
}
}
}
</style>

View File

@ -1,47 +1,30 @@
<script setup lang="ts">
import MenuButton from '@/components/MenuButton.vue'
import SvgIcon from '@/components/SvgIcon.vue'
import type { URLString } from '@/util/data/urlString'
import type { Icon } from '@/util/iconName'
const _props = defineProps<{
name: Icon | URLString
label?: string
disabled?: boolean
}>()
</script>
<template>
<button class="SvgButton button" :class="{ disabled }" :disabled="disabled ?? false">
<SvgIcon :name="name" draggable="false" />
</button>
<MenuButton :disabled="disabled" class="SvgButton">
<SvgIcon :name="name" />
<div v-if="label" v-text="label" />
</MenuButton>
</template>
<style scoped>
.SvgButton {
border: none;
display: flex;
justify-items: center;
align-items: center;
padding: 4px;
cursor: pointer;
margin: -4px -4px;
border-radius: var(--radius-full);
transition: background-color 0.3s;
&:hover {
background-color: var(--color-menu-entry-hover-bg);
}
& > svg {
transform: var(--icon-transform);
transform-origin: var(--icon-transform-origin);
}
margin: -4px;
gap: 4px;
&.disabled {
opacity: 0.3;
&:hover {
background-color: unset;
}
}
}
</style>

View File

@ -27,5 +27,7 @@ svg {
min-width: 16px;
height: 16px;
min-height: 16px;
transform: var(--icon-transform);
transform-origin: var(--icon-transform-origin);
}
</style>

View File

@ -6,27 +6,27 @@
* element.
*/
import SvgButton from '@/components/SvgButton.vue'
import MenuButton from '@/components/MenuButton.vue'
import SvgIcon from '@/components/SvgIcon.vue'
import type { Icon } from '@/util/iconName'
const _props = withDefaults(defineProps<{ icon: Icon; modelValue?: boolean }>(), {
modelValue: false,
})
const emit = defineEmits<{
'update:modelValue': [toggledOn: boolean]
}>()
const toggledOn = defineModel<boolean>({ default: false })
const _props = defineProps<{ icon: Icon; label?: string }>()
</script>
<template>
<SvgButton
:name="icon"
class="ToggleIcon"
:class="{ toggledOn: modelValue, toggledOff: !modelValue }"
@click.stop="emit('update:modelValue', !modelValue)"
/>
<MenuButton v-model="toggledOn" class="ToggleIcon">
<SvgIcon :name="icon" />
<div v-if="label" v-text="label" />
</MenuButton>
</template>
<style scoped>
.ToggleIcon {
margin: -4px;
gap: 4px;
}
.toggledOff {
opacity: 0.4;
}