Merge pull request #15447 from Budibase/bbui-typing-1

Type Checkbox.svelte, CheckboxGroup.svelte, and Combobox.svelte.
This commit is contained in:
Sam Rose 2025-01-28 09:30:55 +00:00 committed by GitHub
commit a8c639687e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 143 additions and 99 deletions

View File

@ -8,27 +8,52 @@
// Strategies are defined as [Popover]To[Anchor]. // Strategies are defined as [Popover]To[Anchor].
// They can apply for both horizontal and vertical alignment. // They can apply for both horizontal and vertical alignment.
const Strategies = { type Strategy =
StartToStart: "StartToStart", // e.g. left alignment | "StartToStart"
EndToEnd: "EndToEnd", // e.g. right alignment | "EndToEnd"
StartToEnd: "StartToEnd", // e.g. right-outside alignment | "StartToEnd"
EndToStart: "EndToStart", // e.g. left-outside alignment | "EndToStart"
MidPoint: "MidPoint", // centers relative to midpoints | "MidPoint"
ScreenEdge: "ScreenEdge", // locks to screen edge | "ScreenEdge"
export interface Styles {
maxHeight?: number
minWidth?: number
maxWidth?: number
offset?: number
left: number
top: number
} }
export default function positionDropdown(element, opts) { export type UpdateHandler = (
let resizeObserver anchorBounds: DOMRect,
elementBounds: DOMRect,
styles: Styles
) => Styles
interface Opts {
anchor?: HTMLElement
align: string
maxHeight?: number
maxWidth?: number
minWidth?: number
useAnchorWidth: boolean
offset: number
customUpdate?: UpdateHandler
resizable: boolean
wrap: boolean
}
export default function positionDropdown(element: HTMLElement, opts: Opts) {
let resizeObserver: ResizeObserver
let latestOpts = opts let latestOpts = opts
// We need a static reference to this function so that we can properly // We need a static reference to this function so that we can properly
// clean up the scroll listener. // clean up the scroll listener.
const scrollUpdate = () => { const scrollUpdate = () => updatePosition(latestOpts)
updatePosition(latestOpts)
}
// Updates the position of the dropdown // Updates the position of the dropdown
const updatePosition = opts => { const updatePosition = (opts: Opts) => {
const { const {
anchor, anchor,
align, align,
@ -51,12 +76,12 @@ export default function positionDropdown(element, opts) {
const winWidth = window.innerWidth const winWidth = window.innerWidth
const winHeight = window.innerHeight const winHeight = window.innerHeight
const screenOffset = 8 const screenOffset = 8
let styles = { let styles: Styles = {
maxHeight, maxHeight,
minWidth: useAnchorWidth ? anchorBounds.width : minWidth, minWidth: useAnchorWidth ? anchorBounds.width : minWidth,
maxWidth: useAnchorWidth ? anchorBounds.width : maxWidth, maxWidth: useAnchorWidth ? anchorBounds.width : maxWidth,
left: null, left: 0,
top: null, top: 0,
} }
// Ignore all our logic for custom logic // Ignore all our logic for custom logic
@ -81,67 +106,67 @@ export default function positionDropdown(element, opts) {
} }
// Applies a dynamic max height constraint if appropriate // Applies a dynamic max height constraint if appropriate
const applyMaxHeight = height => { const applyMaxHeight = (height: number) => {
if (!styles.maxHeight && resizable) { if (!styles.maxHeight && resizable) {
styles.maxHeight = height styles.maxHeight = height
} }
} }
// Applies the X strategy to our styles // Applies the X strategy to our styles
const applyXStrategy = strategy => { const applyXStrategy = (strategy: Strategy) => {
switch (strategy) { switch (strategy) {
case Strategies.StartToStart: case "StartToStart":
default: default:
styles.left = anchorBounds.left styles.left = anchorBounds.left
break break
case Strategies.EndToEnd: case "EndToEnd":
styles.left = anchorBounds.right - elementBounds.width styles.left = anchorBounds.right - elementBounds.width
break break
case Strategies.StartToEnd: case "StartToEnd":
styles.left = anchorBounds.right + offset styles.left = anchorBounds.right + offset
break break
case Strategies.EndToStart: case "EndToStart":
styles.left = anchorBounds.left - elementBounds.width - offset styles.left = anchorBounds.left - elementBounds.width - offset
break break
case Strategies.MidPoint: case "MidPoint":
styles.left = styles.left =
anchorBounds.left + anchorBounds.left +
anchorBounds.width / 2 - anchorBounds.width / 2 -
elementBounds.width / 2 elementBounds.width / 2
break break
case Strategies.ScreenEdge: case "ScreenEdge":
styles.left = winWidth - elementBounds.width - screenOffset styles.left = winWidth - elementBounds.width - screenOffset
break break
} }
} }
// Applies the Y strategy to our styles // Applies the Y strategy to our styles
const applyYStrategy = strategy => { const applyYStrategy = (strategy: Strategy) => {
switch (strategy) { switch (strategy) {
case Strategies.StartToStart: case "StartToStart":
styles.top = anchorBounds.top styles.top = anchorBounds.top
applyMaxHeight(winHeight - anchorBounds.top - screenOffset) applyMaxHeight(winHeight - anchorBounds.top - screenOffset)
break break
case Strategies.EndToEnd: case "EndToEnd":
styles.top = anchorBounds.bottom - elementBounds.height styles.top = anchorBounds.bottom - elementBounds.height
applyMaxHeight(anchorBounds.bottom - screenOffset) applyMaxHeight(anchorBounds.bottom - screenOffset)
break break
case Strategies.StartToEnd: case "StartToEnd":
default: default:
styles.top = anchorBounds.bottom + offset styles.top = anchorBounds.bottom + offset
applyMaxHeight(winHeight - anchorBounds.bottom - screenOffset) applyMaxHeight(winHeight - anchorBounds.bottom - screenOffset)
break break
case Strategies.EndToStart: case "EndToStart":
styles.top = anchorBounds.top - elementBounds.height - offset styles.top = anchorBounds.top - elementBounds.height - offset
applyMaxHeight(anchorBounds.top - screenOffset) applyMaxHeight(anchorBounds.top - screenOffset)
break break
case Strategies.MidPoint: case "MidPoint":
styles.top = styles.top =
anchorBounds.top + anchorBounds.top +
anchorBounds.height / 2 - anchorBounds.height / 2 -
elementBounds.height / 2 elementBounds.height / 2
break break
case Strategies.ScreenEdge: case "ScreenEdge":
styles.top = winHeight - elementBounds.height - screenOffset styles.top = winHeight - elementBounds.height - screenOffset
applyMaxHeight(winHeight - 2 * screenOffset) applyMaxHeight(winHeight - 2 * screenOffset)
break break
@ -150,81 +175,78 @@ export default function positionDropdown(element, opts) {
// Determine X strategy // Determine X strategy
if (align === "right") { if (align === "right") {
applyXStrategy(Strategies.EndToEnd) applyXStrategy("EndToEnd")
} else if (align === "right-outside" || align === "right-context-menu") { } else if (align === "right-outside" || align === "right-context-menu") {
applyXStrategy(Strategies.StartToEnd) applyXStrategy("StartToEnd")
} else if (align === "left-outside" || align === "left-context-menu") { } else if (align === "left-outside" || align === "left-context-menu") {
applyXStrategy(Strategies.EndToStart) applyXStrategy("EndToStart")
} else if (align === "center") { } else if (align === "center") {
applyXStrategy(Strategies.MidPoint) applyXStrategy("MidPoint")
} else { } else {
applyXStrategy(Strategies.StartToStart) applyXStrategy("StartToStart")
} }
// Determine Y strategy // Determine Y strategy
if (align === "right-outside" || align === "left-outside") { if (align === "right-outside" || align === "left-outside") {
applyYStrategy(Strategies.MidPoint) applyYStrategy("MidPoint")
} else if ( } else if (
align === "right-context-menu" || align === "right-context-menu" ||
align === "left-context-menu" align === "left-context-menu"
) { ) {
applyYStrategy(Strategies.StartToStart) applyYStrategy("StartToStart")
styles.top -= 5 // Manual adjustment for action menu padding if (styles.top) {
styles.top -= 5 // Manual adjustment for action menu padding
}
} else { } else {
applyYStrategy(Strategies.StartToEnd) applyYStrategy("StartToEnd")
} }
// Handle screen overflow // Handle screen overflow
if (doesXOverflow()) { if (doesXOverflow()) {
// Swap left to right // Swap left to right
if (align === "left") { if (align === "left") {
applyXStrategy(Strategies.EndToEnd) applyXStrategy("EndToEnd")
} }
// Swap right-outside to left-outside // Swap right-outside to left-outside
else if (align === "right-outside") { else if (align === "right-outside") {
applyXStrategy(Strategies.EndToStart) applyXStrategy("EndToStart")
} }
} }
if (doesYOverflow()) { if (doesYOverflow()) {
// If wrapping, lock to the bottom of the screen and also reposition to // If wrapping, lock to the bottom of the screen and also reposition to
// the side to not block the anchor // the side to not block the anchor
if (wrap) { if (wrap) {
applyYStrategy(Strategies.MidPoint) applyYStrategy("MidPoint")
if (doesYOverflow()) { if (doesYOverflow()) {
applyYStrategy(Strategies.ScreenEdge) applyYStrategy("ScreenEdge")
} }
applyXStrategy(Strategies.StartToEnd) applyXStrategy("StartToEnd")
if (doesXOverflow()) { if (doesXOverflow()) {
applyXStrategy(Strategies.EndToStart) applyXStrategy("EndToStart")
} }
} }
// Othewise invert as normal // Othewise invert as normal
else { else {
// If using an outside strategy then lock to the bottom of the screen // If using an outside strategy then lock to the bottom of the screen
if (align === "left-outside" || align === "right-outside") { if (align === "left-outside" || align === "right-outside") {
applyYStrategy(Strategies.ScreenEdge) applyYStrategy("ScreenEdge")
} }
// Otherwise flip above // Otherwise flip above
else { else {
applyYStrategy(Strategies.EndToStart) applyYStrategy("EndToStart")
} }
} }
} }
} }
// Apply styles for (const [key, value] of Object.entries(styles)) {
Object.entries(styles).forEach(([style, value]) => { element.style.setProperty(key, value ? `${value}px` : null)
if (value != null) { }
element.style[style] = `${value.toFixed(0)}px`
} else {
element.style[style] = null
}
})
} }
// The actual svelte action callback which creates observers on the relevant // The actual svelte action callback which creates observers on the relevant
// DOM elements // DOM elements
const update = newOpts => { const update = (newOpts: Opts) => {
latestOpts = newOpts latestOpts = newOpts
// Cleanup old state // Cleanup old state

View File

@ -1,22 +1,23 @@
<script> <script lang="ts">
import "@spectrum-css/checkbox/dist/index-vars.css" import "@spectrum-css/checkbox/dist/index-vars.css"
import "@spectrum-css/fieldgroup/dist/index-vars.css" import "@spectrum-css/fieldgroup/dist/index-vars.css"
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
import type { ChangeEventHandler } from "svelte/elements"
export let value = false export let value = false
export let id = null export let id: string | undefined = undefined
export let text = null export let text: string | undefined = undefined
export let disabled = false export let disabled = false
export let readonly = false export let readonly = false
export let size export let size: "S" | "M" | "L" | "XL" = "M"
export let indeterminate = false export let indeterminate = false
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const onChange = event => { const onChange: ChangeEventHandler<HTMLInputElement> = event => {
dispatch("change", event.target.checked) dispatch("change", event.currentTarget.checked)
} }
$: sizeClass = `spectrum-Checkbox--size${size || "M"}` $: sizeClass = `spectrum-Checkbox--size${size}`
</script> </script>
<label <label

View File

@ -1,19 +1,24 @@
<script> <script lang="ts" context="module">
type O = any
type V = any
</script>
<script lang="ts" generics="O, V">
import "@spectrum-css/fieldgroup/dist/index-vars.css" import "@spectrum-css/fieldgroup/dist/index-vars.css"
import "@spectrum-css/radio/dist/index-vars.css" import "@spectrum-css/radio/dist/index-vars.css"
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
export let direction = "vertical" export let direction: "horizontal" | "vertical" = "vertical"
export let value = [] export let value: V[] = []
export let options = [] export let options: O[] = []
export let disabled = false export let disabled = false
export let readonly = false export let readonly = false
export let getOptionLabel = option => option export let getOptionLabel = (option: O) => `${option}`
export let getOptionValue = option => option export let getOptionValue = (option: O) => option as unknown as V
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher<{ change: V[] }>()
const onChange = optionValue => { const onChange = (optionValue: V) => {
if (!value.includes(optionValue)) { if (!value.includes(optionValue)) {
dispatch("change", [...value, optionValue]) dispatch("change", [...value, optionValue])
} else { } else {

View File

@ -1,4 +1,10 @@
<script> <script lang="ts" context="module">
type O = any
</script>
<script lang="ts" generics="O">
import type { ChangeEventHandler } from "svelte/elements"
import "@spectrum-css/inputgroup/dist/index-vars.css" import "@spectrum-css/inputgroup/dist/index-vars.css"
import "@spectrum-css/popover/dist/index-vars.css" import "@spectrum-css/popover/dist/index-vars.css"
import "@spectrum-css/menu/dist/index-vars.css" import "@spectrum-css/menu/dist/index-vars.css"
@ -6,33 +12,38 @@
import clickOutside from "../../Actions/click_outside" import clickOutside from "../../Actions/click_outside"
import Popover from "../../Popover/Popover.svelte" import Popover from "../../Popover/Popover.svelte"
export let value = null export let value: string | undefined = undefined
export let id = null export let id: string | undefined = undefined
export let placeholder = "Choose an option or type" export let placeholder = "Choose an option or type"
export let disabled = false export let disabled = false
export let readonly = false export let readonly = false
export let options = [] export let options: O[] = []
export let getOptionLabel = option => option export let getOptionLabel = (option: O) => `${option}`
export let getOptionValue = option => option export let getOptionValue = (option: O) => `${option}`
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher<{
change: string
blur: void
type: string
pick: string
}>()
let open = false let open = false
let focus = false let focus = false
let anchor let anchor
const selectOption = value => { const selectOption = (value: string) => {
dispatch("change", value) dispatch("change", value)
open = false open = false
} }
const onType = e => { const onType: ChangeEventHandler<HTMLInputElement> = e => {
const value = e.target.value const value = e.currentTarget.value
dispatch("type", value) dispatch("type", value)
selectOption(value) selectOption(value)
} }
const onPick = value => { const onPick = (value: string) => {
dispatch("pick", value) dispatch("pick", value)
selectOption(value) selectOption(value)
} }

View File

@ -1,28 +1,33 @@
<script> <script lang="ts">
import "@spectrum-css/popover/dist/index-vars.css" import "@spectrum-css/popover/dist/index-vars.css"
// @ts-expect-error no types for the version of svelte-portal we're on.
import Portal from "svelte-portal" import Portal from "svelte-portal"
import { createEventDispatcher, getContext, onDestroy } from "svelte" import { createEventDispatcher, getContext, onDestroy } from "svelte"
import positionDropdown from "../Actions/position_dropdown" import positionDropdown, {
type UpdateHandler,
} from "../Actions/position_dropdown"
import clickOutside from "../Actions/click_outside" import clickOutside from "../Actions/click_outside"
import { fly } from "svelte/transition" import { fly } from "svelte/transition"
import Context from "../context" import Context from "../context"
import type { KeyboardEventHandler } from "svelte/elements"
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher<{ open: void; close: void }>()
export let anchor export let anchor: HTMLElement
export let align = "right" export let align: "left" | "right" | "left-outside" | "right-outside" =
export let portalTarget "right"
export let minWidth export let portalTarget: string | undefined = undefined
export let maxWidth export let minWidth: number | undefined = undefined
export let maxHeight export let maxWidth: number | undefined = undefined
export let maxHeight: number | undefined = undefined
export let open = false export let open = false
export let useAnchorWidth = false export let useAnchorWidth = false
export let dismissible = true export let dismissible = true
export let offset = 4 export let offset = 4
export let customHeight export let customHeight: string | undefined = undefined
export let animate = true export let animate = true
export let customZindex export let customZindex: string | undefined = undefined
export let handlePostionUpdate export let handlePostionUpdate: UpdateHandler | undefined = undefined
export let showPopover = true export let showPopover = true
export let clickOutsideOverride = false export let clickOutsideOverride = false
export let resizable = true export let resizable = true
@ -30,7 +35,7 @@
const animationDuration = 260 const animationDuration = 260
let timeout let timeout: ReturnType<typeof setTimeout>
let blockPointerEvents = false let blockPointerEvents = false
$: target = portalTarget || getContext(Context.PopoverRoot) || ".spectrum" $: target = portalTarget || getContext(Context.PopoverRoot) || ".spectrum"
@ -65,13 +70,13 @@
} }
} }
const handleOutsideClick = e => { const handleOutsideClick = (e: MouseEvent) => {
if (clickOutsideOverride) { if (clickOutsideOverride) {
return return
} }
if (open) { if (open) {
// Stop propagation if the source is the anchor // Stop propagation if the source is the anchor
let node = e.target let node = e.target as Node | null
let fromAnchor = false let fromAnchor = false
while (!fromAnchor && node && node.parentNode) { while (!fromAnchor && node && node.parentNode) {
fromAnchor = node === anchor fromAnchor = node === anchor
@ -86,7 +91,7 @@
} }
} }
function handleEscape(e) { const handleEscape: KeyboardEventHandler<HTMLDivElement> = e => {
if (!clickOutsideOverride) { if (!clickOutsideOverride) {
return return
} }