SplitButton
The usual action as a button, joined to a menu of the other ways to do it, with the keyboard.
Demo
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.qwemain.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>}Related
BookmarkButtonA save-for-later toggle whose bookmark fills when pressed, with or without its label.8 views · 8 downloadsButtonA button, or a link that looks like one: four variants, three tones, three sizes, loading.7 views · 7 downloadsButtonGroupButtons joined edge to edge into one control, or spaced in a row; across or upright.6 views · 6 downloadsDropdownMenuA button that opens a short list of links and actions; Escape or a click elsewhere closes it.6 views · 7 downloadsFabA round floating action button fixed to a corner of the screen, with an icon and an optional label.5 views · 7 downloadsFollowButtonFollow becomes Following once pressed, and says Unfollow on hover, as the social sites do.5 views · 7 downloads
Thread
Thread 0 posts
On SplitButton: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.