Skip to content

Commit

Permalink
Text Editor: Add heading block types
Browse files Browse the repository at this point in the history
- Fix mapped State generators leaking by default
- Make FocusListener.focused a State to allow listening for changes
- Add support for enter/space to trigger clicks on fake buttons
- Fix buttons in masthead not clearing popovers
- Add support for popovers within popovers
  • Loading branch information
ChiriVulpes committed Oct 27, 2024
1 parent 5bbd575 commit 0b24076
Show file tree
Hide file tree
Showing 12 changed files with 293 additions and 78 deletions.
4 changes: 2 additions & 2 deletions src/App.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ async function App (): Promise<App> {

await screen?.orientation?.lock?.("portrait-primary").catch(() => { })

InputBus.subscribe("keydown", event => {
InputBus.subscribe("down", event => {
if (event.use("F6"))
for (const stylesheet of document.querySelectorAll("link[rel=stylesheet]")) {
const href = stylesheet.getAttribute("href")!
Expand All @@ -50,7 +50,7 @@ async function App (): Promise<App> {
if (event.use("F4"))
document.documentElement.classList.add("persist-tooltips")
})
InputBus.subscribe("keyup", event => {
InputBus.subscribe("up", event => {
if (event.use("F4"))
document.documentElement.classList.remove("persist-tooltips")
})
Expand Down
15 changes: 14 additions & 1 deletion src/ui/Component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,9 @@ interface BaseComponent {
append (...contents: (Component | Node)[]): this
prepend (...contents: (Component | Node)[]): this

closest<COMPONENT extends Component> (builder: Component.Builder<any[], COMPONENT>): COMPONENT | undefined
closest<COMPONENT extends Component> (builder: Component.Extension<any[], COMPONENT>): COMPONENT | undefined

getAncestorComponents (): Generator<Component>

remove (): void
Expand Down Expand Up @@ -242,7 +245,7 @@ function Component (type: keyof HTMLElementTagNameMap = "span"): Component {
get hoveredOrFocused (): State<boolean> {
return Define.set(component, "hoveredOrFocused",
State.Generator(() => component.hovered.value || component.focused.value)
.observe(component.hovered, component.focused))
.observe(component, component.hovered, component.focused))
},
get active (): State<boolean> {
return Define.set(component, "active", State(false))
Expand Down Expand Up @@ -364,6 +367,16 @@ function Component (type: keyof HTMLElementTagNameMap = "span"): Component {
return component
},

closest (builder) {
let cursor: HTMLElement | null = component.element
while (cursor) {
cursor = cursor.parentElement
const component = cursor?.component
if (component?.is(builder))
return component
}
},

*getAncestorComponents () {
let cursor: HTMLElement | null = component.element
while (cursor) {
Expand Down
45 changes: 39 additions & 6 deletions src/ui/InputBus.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,27 @@
import Component from "ui/Component"
import { EventManager } from "utility/EventManager"

enum Classes {
ReceiveFocusedClickEvents = "_receieve-focused-click-events",
}

interface InputBusComponentExtensions {
receiveFocusedClickEvents (): this
}

declare module "ui/Component" {
interface ComponentExtensions extends InputBusComponentExtensions { }
}

Component.extend(component => {
component.extend<InputBusComponentExtensions>(component => ({
receiveFocusedClickEvents: () => component.classes.add(Classes.ReceiveFocusedClickEvents),
}))
})

type Modifier = "ctrl" | "shift" | "alt"

export interface IKeyEvent {
export interface IInputEvent {
key: string
ctrl: boolean
shift: boolean
Expand All @@ -16,13 +35,13 @@ export interface IKeyEvent {
hovering (selector?: string): HTMLElement | undefined
}

export interface IKeyUpEvent extends IKeyEvent {
export interface IInputUpEvent extends IInputEvent {
usedAnotherKeyDuring: boolean
}

export interface IInputBusEvents {
keydown: IKeyEvent
keyup: IKeyUpEvent
down: IInputEvent
up: IInputUpEvent
}

const MOUSE_KEYNAME_MAP: Record<string, string> = {
Expand Down Expand Up @@ -56,13 +75,27 @@ function emitKeyEvent (e: RawEvent) {
const input = target.closest<HTMLElement>("input[type=text], textarea, [contenteditable]")
let usedByInput = !!input

const isClick = true
&& !usedByInput
&& e.type === "keydown"
&& (e.key === "Enter" || e.key === "Space")
&& !e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey
&& target.classList.contains(Classes.ReceiveFocusedClickEvents)
if (isClick) {
const result = target.component?.event.emit("click")
if (result?.defaultPrevented) {
e.preventDefault()
return
}
}

const eventKey = e.key ?? MOUSE_KEYNAME_MAP[e.button!]
const eventType = e.type === "mousedown" ? "keydown" : e.type === "mouseup" ? "keyup" : e.type as "keydown" | "keyup"
if (eventType === "keydown")
inputDownTime[eventKey] = Date.now()

let cancelInput = false
const event: IKeyEvent & Partial<IKeyUpEvent> = {
const event: IInputEvent & Partial<IInputUpEvent> = {
key: eventKey,
ctrl: e.ctrlKey,
shift: e.shiftKey,
Expand Down Expand Up @@ -112,7 +145,7 @@ function emitKeyEvent (e: RawEvent) {
delete inputDownTime[eventKey]
}

InputBus.emit(eventType, event)
InputBus.emit(eventType === "keydown" ? "down" : "up", event)

if ((event.used && !usedByInput) || (usedByInput && cancelInput)) {
e.preventDefault()
Expand Down
4 changes: 4 additions & 0 deletions src/ui/component/Masthead.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ const Masthead = Component.Builder("header", (masthead, view: ViewContainer) =>
.append(Button()
.style("masthead-left-hamburger", "masthead-left-hamburger-popover")
.ariaLabel.use("masthead/primary-nav/alt")
.clearPopover()
.setPopover("hover", p => popover = p
.anchor.add("aligned left", "off bottom")
.ariaRole("navigation")))
Expand All @@ -59,6 +60,7 @@ const Masthead = Component.Builder("header", (masthead, view: ViewContainer) =>

const homeLink = Link("/")
.ariaLabel.use("fluff4me/alt")
.clearPopover()
.append(Heading()
.and(Button)
.style("masthead-home")
Expand All @@ -80,9 +82,11 @@ const Masthead = Component.Builder("header", (masthead, view: ViewContainer) =>
.style("masthead-user")
.append(Button()
.style("masthead-user-notifications")
.clearPopover()
.ariaLabel.use("masthead/user/notifications/alt"))
.append(Button()
.style("masthead-user-profile")
.clearPopover()
.ariaLabel.use("masthead/user/profile/alt")
.setPopover("hover", popover => popover
.anchor.add("aligned right", "off bottom")
Expand Down
110 changes: 100 additions & 10 deletions src/ui/component/core/Popover.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import Component from "ui/Component"
import type { IInputEvent } from "ui/InputBus"
import InputBus from "ui/InputBus"
import FocusListener from "ui/utility/FocusListener"
import HoverListener from "ui/utility/HoverListener"
import Mouse from "ui/utility/Mouse"
import type { UnsubscribeState } from "utility/State"
Expand Down Expand Up @@ -63,7 +65,7 @@ Component.extend(component => {
popover.focus()
})

popover.hasFocused.subscribe(component, hasFocused => {
popover.popoverHasFocus.subscribe(component, hasFocused => {
if (hasFocused)
return

Expand All @@ -72,24 +74,36 @@ Component.extend(component => {
component.focus()
})

component.receiveAncestorInsertEvents()
component.event.subscribe(["insert", "ancestorInsert"], updatePopoverParent)

return component.extend<PopoverComponentRegisteredExtensions>(component => ({
popover,
popoverDescendants: [],
tweakPopover: (initialiser) => {
initialiser(popover, component)
return component
},
}))

function updatePopoverParent () {
const oldParent = popover.popoverParent.value
popover.popoverParent.value = component.closest(Popover)
if (oldParent && oldParent !== popover.popoverParent.value)
oldParent.popoverChildren.value = oldParent.popoverChildren.value.filter(c => c !== popover)

if (popover.popoverParent.value && popover.popoverParent.value !== oldParent)
popover.popoverParent.value.popoverChildren.value = [...popover.popoverParent.value.popoverChildren.value, popover]
}

async function updatePopoverState () {
const shouldShow = false
|| component.hoveredOrFocused.value
|| (true
&& isShown
&& (false
|| popover.rect.value.expand(+popover.attributes.get("data-popover-mouse-padding")! || 100).intersects(Mouse.state.value)
|| InputBus.isDown("F4")
)
&& (popover.element.contains(HoverListener.hovered() ?? null) || !HoverListener.hovered()?.closest("[data-clear-popover]"))
|| (popover.isMouseWithin(true) && !shouldClearPopover())
|| InputBus.isDown("F4"))
)
|| clickState

Expand All @@ -115,6 +129,22 @@ Component.extend(component => {
await Task.yield()
popover.anchor.apply()
}

function shouldClearPopover () {
const hovered = HoverListener.hovered() ?? null
if (component.element.contains(hovered) || popover.element.contains(hovered))
return false

const clearsPopover = hovered?.closest("[data-clear-popover]")
if (!clearsPopover)
return false

const clearsPopoverWithinPopover = clearsPopover.component?.closest(Popover)
if (popover.containsPopoverDescendant(clearsPopoverWithinPopover))
return false

return true
}
},
}))
})
Expand All @@ -125,27 +155,56 @@ declare module "ui/Component" {

interface PopoverExtensions {
visible: State<boolean>
popoverChildren: State<readonly Popover[]>
popoverParent: State<Popover | undefined>
popoverHasFocus: State<boolean>

/** Sets the distance the mouse can be from the popover before it hides, if it's shown due to hover */
setMousePadding (padding?: number): this

isMouseWithin (checkDescendants?: true): boolean
containsPopoverDescendant (node?: Node | Component): boolean

show (): this
hide (): this
toggle (shown?: boolean): this
bind (state: State<boolean>): this
unbind (): this
/** Sets the distance the mouse can be from the popover before it hides, if it's shown due to hover */
setMousePadding (padding?: number): this
}

interface Popover extends Component, PopoverExtensions { }

const Popover = Component.Builder((component): Popover => {
let mousePadding: number | undefined
let unbind: UnsubscribeState | undefined
const popover = component
.style("popover")
.tabIndex("programmatic")
.attributes.add("popover")
.attributes.set("popover", "manual")
.extend<PopoverExtensions>(popover => ({
visible: State(false),
setMousePadding: (padding) =>
popover.attributes.set("data-popover-mouse-padding", padding === undefined ? undefined : `${padding}`),
popoverChildren: State([]),
popoverParent: State(undefined),
popoverHasFocus: FocusListener.focused.map(popover, containsPopoverDescendant),

setMousePadding: (padding) => {
mousePadding = padding
return popover
},

isMouseWithin: (checkDescendants: boolean = false) => {
if (popover.rect.value.expand(mousePadding ?? 100).intersects(Mouse.state.value))
return true

if (checkDescendants)
for (const child of popover.popoverChildren.value)
if (child.isMouseWithin(true))
return true

return false
},
containsPopoverDescendant,

show: () => {
unbind?.()
popover.element.togglePopover(true)
Expand Down Expand Up @@ -182,7 +241,38 @@ const Popover = Component.Builder((component): Popover => {
popover.visible.value = event.newState === "open"
})

InputBus.subscribe("down", onInputDown)
component.event.subscribe("remove", () => InputBus.unsubscribe("down", onInputDown))

return popover

function onInputDown (event: IInputEvent) {
if (!popover.visible.value)
return

if (!event.key.startsWith("Mouse") || popover.containsPopoverDescendant(HoverListener.hovered()))
return

popover.element.togglePopover(false)
popover.visible.value = false
}

function containsPopoverDescendant (descendant?: Node | Component) {
if (!descendant)
return false

const node = Component.is(descendant) ? descendant.element : descendant
if (popover.element.contains(node))
return true

for (const child of popover.popoverChildren.value)
if (child === descendant)
return true
else if (child.containsPopoverDescendant(descendant))
return true

return false
}
})

export default Popover
Loading

0 comments on commit 0b24076

Please sign in to comment.