Actions

SplitButton

The usual action as a button, joined to a menu of the other ways to do it, with the keyboard.

#forms#button#menu#no-js

Demo

Preview
In a form: each choice sends action=…
Did: nothing yet

Get it

It needs theme.qwe, as every component does; or take the whole kit, quillo-ui.qwe. Each component brings its own styles to the pages that show it.

$ curl -O https://quillolang.org/components/ui/theme.qwe$ curl -O https://quillolang.org/components/ui/split_button.qwe
main.qwe
import ui/theme.qwe use theme_cssuse ui/split_button.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>

Example

The code of the preview above.

demos/split_button.qwe
component SplitButtonDemoLive() {    state did = "nothing yet"    <div.demo-row>        <SplitButton id="split-demo-merge" label="Merge pull request" variant="outline" tone="neutral"                     on_click={() => { did = "merged" }} on_select={(it) => { did = it.label }}                     items={[{ label: "Squash and merge", value: "squash" }, { label: "Rebase and merge", value: "rebase" },                             { label: "Merge when checks pass", disabled: true }]}/>        <span.demo-note>Did: {did}</span>    </div>}view SplitButtonDemo() =    <div.demo-stack>        <form.demo-row method="get" action="#split_button">            <SplitButton id="split-demo-save" label="Save" type="submit" name="action" value="save"                         items={[{ label: "Save as draft", value: "draft" }, { label: "Save and publish", value: "publish" },                                 { divider: true }, { label: "Version history", href: "#split_button" }]}/>            <span.demo-note>In a form: each choice sends action=…</span>        </form>        <div.demo-row>            <SplitButton id="split-demo-export" label="Export" variant="soft" size="sm"                         items={[{ label: "As CSV" }, { label: "As JSON" }]}/>            <SplitButton id="split-demo-delete" label="Delete" tone="danger"                         items={[{ label: "Delete and block sender", tone: "danger" }, { label: "Archive instead" }]}/>            <SplitButton id="split-demo-off" label="Deploy" variant="outline" disabled={true} items={[{ label: "Deploy to staging" }]}/>        </div>        <SplitButtonDemoLive/>    </div>

Source

ui/split_button.qwe
// SplitButton: a button for the usual action, joined to a small one that opens a// menu of the other ways to do it ("Save" / "Save as draft", "Merge" / "Squash and// merge"). The menu is an ARIA menu in the browser's `popover`: the arrow keys,// Home and End move through it, Enter picks, Escape or a click elsewhere closes it.////   <SplitButton id="save" label="Save" name="action" value="save" type="submit"//                items={[{ label: "Save as draft", value: "draft" }, { label: "Save and publish", value: "publish" }]}/>//   <SplitButton id="merge" label="Merge" variant="outline" tone="neutral"//                on_click={() => { merge("merge") }} on_select={(it) => { merge(it.value) }}//                items={[{ label: "Squash and merge", value: "squash" }, { label: "Rebase and merge", value: "rebase" }]}/>//// id (required): unique on the page; the main button's id, and the menu's starts with it.// label: the main button's text. type ("button"), name, value: the main button's,// so in a <form> it can send name=value. items: a list of { label, value?, href?,// tone?: "danger", disabled?: true }, or { divider: true } for a line between groups.// An item with an href is a link; one with a value, when name is given, is a submit// button sending name=value; any other calls on_select(item).// variant: "solid" (default), "soft" or "outline"; tone: "accent" (default),// "neutral" or "danger"; size: "sm", "md" or "lg", as a Button (it is styled as one// when button_css is on the page). disabled: both buttons. menu_label: the small// button's name for screen readers ("More options").// on_click(): the main button, and on_select(item): an item, inside one of your components.import ui/theme.qwe use ui_clslet split_button_css = css`.ui-split { display: inline-flex; align-items: stretch; vertical-align: middle; }.ui-split > .ui-btn:first-child { border-top-right-radius: 0; border-bottom-right-radius: 0; }.ui-split > .ui-split-toggle { width: 34px; padding: 0; border-top-left-radius: 0; border-bottom-left-radius: 0; }.ui-split > .ui-btn-outline.ui-split-toggle { margin-left: -1px; }.ui-split > .ui-btn-solid.ui-split-toggle, .ui-split > .ui-btn-soft.ui-split-toggle {  border-left: 1px solid color-mix(in srgb, var(--ui-bg) 35%, transparent);}.ui-split > .ui-btn:hover, .ui-split > .ui-btn:focus-visible { position: relative; z-index: 1; }.ui-split > .ui-btn-sm.ui-split-toggle { width: 28px; }.ui-split > .ui-btn-lg.ui-split-toggle { width: 42px; }.ui-split-toggle svg { width: 16px; height: 16px; transition: transform .15s; }.ui-split-toggle[aria-expanded="true"] svg { transform: rotate(180deg); }.ui-split-menu {  min-width: 200px; max-width: min(320px, calc(100vw - 16px)); margin: auto; padding: 5px; list-style: none;  border: 1px solid var(--ui-border); border-radius: var(--ui-radius); background: var(--ui-surface);  color: var(--ui-fg); box-shadow: var(--ui-shadow-lg); font: 14px/1.4 var(--ui-font);}.ui-split-menu li { display: block; }.ui-split-item {  display: flex; align-items: center; width: 100%; padding: 8px 10px; border: 0; border-radius: var(--ui-radius-sm);  background: transparent; color: var(--ui-fg); font: inherit; text-align: left; text-decoration: none; cursor: pointer;}.ui-split-item:hover, .ui-split-item:focus { outline: none; background: var(--ui-subtle); }.ui-split-item:focus-visible { box-shadow: var(--ui-focus); }.ui-split-item[aria-disabled="true"] { opacity: .5; cursor: not-allowed; background: transparent; }.ui-split-danger { color: var(--ui-danger); }.ui-split-danger:hover, .ui-split-danger:focus { background: var(--ui-danger-soft); }.ui-split-sep { height: 1px; margin: 4px 6px; background: var(--ui-border); }.ui-split-menu:popover-open { animation: ui-split-in .12s ease-out; }@keyframes ui-split-in { from { opacity: 0; transform: translateY(-4px); } }@supports (position-area: bottom) {  .ui-split-menu { inset: auto; margin: 4px 0; position-area: bottom span-left; position-try-fallbacks: flip-block, flip-inline; }}@supports not (position-area: bottom) { .ui-split-menu::backdrop { background: var(--ui-overlay); } }@media (prefers-reduced-motion: reduce) { .ui-split-menu:popover-open { animation: none; } .ui-split-toggle svg { transition: none; } }`component SplitButton(id: str, label: str = "", items: list = [], variant: str = "solid", tone: str = "accent",                      size: str = "md", type: str = "button", name: str? = null, value: str? = null,                      disabled: bool = false, menu_label: str = "More options", on_click = null, on_select = null,                      class: str? = null) {    style global split_button_css    state open = false    state menu = null    state toggle = null    state last = false    let menu_id = `${id}-menu`    let btn = ui_cls(["ui-btn", `ui-btn-${variant}`, tone != "accent" && `ui-btn-${tone}`, size != "md" && `ui-btn-${size}`])    let entries = () => js("Array.from", menu.querySelectorAll("[role=menuitem]:not([aria-disabled=true])"))    let move = (e, by: int) => {        let found = entries()        let n = len(found)        let at = reduce(enumerate(found), null, (got, p) => p[1].isSameNode(e.target) ? p[0] : got)        if n > 0 {            let to = by == 0 ? 0 : by == 99 ? n - 1 : at == null ? 0 : (at + by + n) % n            found[to].focus()        }    }    <span class={ui_cls(["ui-split", class])} style={`anchor-name: --${id}`}>        <button class={btn} id={id} type={type} name={name} value={value} disabled={disabled}                @click={() => { on_click && on_click() }}>{label}</button>        <button class={ui_cls([btn, "ui-split-toggle"])} type="button" id={`${id}-toggle`} popovertarget={menu_id}                aria-haspopup="menu" aria-expanded={open ? "true" : "false"} aria-controls={menu_id}                aria-label={menu_label} title={menu_label} disabled={disabled}                @mount={(el) => { toggle = el }}                @keydown={(e) => {                    if (e.key == "ArrowDown" || e.key == "ArrowUp") && !open {                        e.preventDefault()                        last = e.key == "ArrowUp"                        menu.showPopover()                    }                }}>            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"                 stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg>        </button>        <ul.ui-split-menu id={menu_id} popover="auto" role="menu" aria-labelledby={`${id}-toggle`}            style={`position-anchor: --${id}`}            @mount={(el) => { menu = el }}            @toggle={(e) => {                open = e.newState == "open"                if open {                    let found = entries()                    if len(found) > 0 { (last ? found[len(found) - 1] : found[0]).focus() }                    last = false                } else if menu.contains(js("document.activeElement")) {                    toggle.focus()                }            }}            @keydown={(e) => {                let by = match e.key {                    "ArrowDown" => 1                    "ArrowUp" => -1                    "Home" => 0                    "End" => 99                    _ => null                }                if by != null {                    e.preventDefault()                    move(e, by)                } else if e.key == "Escape" {                    e.preventDefault()                    menu.hidePopover()                    toggle.focus()                } else if e.key == "Tab" {                    menu.hidePopover()                }            }}>            {for it in items => get(it, "divider") == true                ? <li.ui-split-sep role="separator"></li>                : {                    let cls = ui_cls(["ui-split-item", get(it, "tone") == "danger" && "ui-split-danger"])                    let off = get(it, "disabled") == true                    <li role="none">{get(it, "href") != null                        ? <a class={cls} role="menuitem" tabindex="-1" href={off ? null : it.href}                             aria-disabled={off ? "true" : null}>{it.label}</a>                        : <button class={cls} role="menuitem" tabindex="-1"                                  type={name != null && get(it, "value") != null && !off ? "submit" : "button"}                                  name={name != null && get(it, "value") != null ? name : null} value={get(it, "value")}                                  aria-disabled={off ? "true" : null}                                  @click={(e) => {                                      if off { e.preventDefault() } else {                                          menu.hidePopover()                                          on_select && on_select(it)                                      }                                  }}>{it.label}</button>}</li>                }}        </ul>    </span>}
Thread
54Useful? Like it, or add to its thread below.

Thread 0 posts

On SplitButton: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.