拆分按钮

此模式展示了如何构建自适应且可访问的拆分按钮组件。

完整文章 · YouTube 上的视频 · GitHub 上的来源

<div class="gui-split-button">
 
<button>View Cart</button>
 
<span class="gui-popup-button" aria-haspopup="true" aria-expanded="false" title="Open for more actions">
   
<svg aria-hidden="true" viewBox="0 0 20 20">
     
<path d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" />
   
</svg>
   
<ul class="gui-popup">
     
<li><button>
       
<svg aria-hidden="true" viewBox="0 0 24 24">
         
<path d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z" />
       
</svg>
        Checkout
     
</button></li>
     
<li><button>
       
<svg aria-hidden="true" viewBox="0 0 24 24">
         
<path d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z" />
       
</svg>
        Quick Pay
     
</button></li>
     
<li><button>
       
<svg aria-hidden="true" viewBox="0 0 24 24">
         
<path d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
       
</svg>
        Save for later
     
</button></li>
   
</ul>
 
</span>
</div>

<div class="gui-split-button">
 
<button>Send</button>
 
<span class="gui-popup-button" aria-haspopup="true" aria-expanded="false" title="Open for more actions">
   
<svg aria-hidden="true" viewBox="0 0 20 20">
     
<path d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" />
   
</svg>
   
<ul class="gui-popup">
     
<li><button>
       
<svg aria-hidden="true" viewBox="0 0 24 24">
         
<path d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
       
</svg>
        Schedule for later
     
</button></li>
     
<li><button>
       
<svg aria-hidden="true" viewBox="0 0 24 24">
         
<path d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
       
</svg>
        Delete
     
</button></li>
     
<li><button>
       
<svg aria-hidden="true" viewBox="0 0 24 24">
         
<path d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
       
</svg>
        Save draft
     
</button></li>
   
</ul>
 
</span>
</div>

<div class="gui-split-button">
 
<button>Squash</button>
 
<span class="gui-popup-button" aria-haspopup="true" aria-expanded="false" title="Open for more actions">
   
<svg aria-hidden="true" viewBox="0 0 20 20">
     
<path d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" />
   
</svg>
   
<ul class="gui-popup">
     
<li><button>
        Create a merge commit
     
</button></li>
     
<li><button>
        Rebase
     
</button></li>
   
</ul>
 
</span>
</div>

       
.gui-split-button {
 
--theme:        hsl(220 75% 50%);
 
--theme-hover:  hsl(220 75% 45%);
 
--theme-active: hsl(220 75% 40%);
 
--theme-text:   hsl(220 75% 25%);
 
--theme-border: hsl(220 50% 75%);
 
--ontheme:      hsl(220 90% 98%);
 
--popupbg:      hsl(220 0% 100%);

 
--border: 1px solid var(--theme-border);
 
--radius: 6px;
 
--in-speed: 500ms;
 
--out-speed: 100ms;

 
display: inline-flex;
 
border-radius: var(--radius);
 
background: var(--theme);
 
color: var(--ontheme);
 
fill: var(--ontheme);

 
touch-action: manipulation;
 
user-select: none;
 
-webkit-tap-highlight-color: transparent;

 
@media (--dark) {
   
--theme:        hsl(220 50% 60%);
   
--theme-hover:  hsl(220 50% 65%);
   
--theme-active: hsl(220 75% 70%);
   
--theme-text:   hsl(220 10% 85%);
   
--theme-border: hsl(220 20% 70%);
   
--ontheme:      hsl(220 90% 5%);
   
--popupbg:      hsl(220 10% 30%);
 
}

 
& button {
   
cursor: pointer;
   
appearance: none;
   
background: none;
   
border: none;

   
display: inline-flex;
   
align-items: center;
   
gap: 1ch;
   
white-space: nowrap;

   
font-family: inherit;
   
font-size: inherit;
   
font-weight: 500;

   
padding-block: 1.25ch;
   
padding-inline: 2.5ch;

   
color: var(--ontheme);
   
outline-color: var(--theme);
   
outline-offset: -5px;

   
&:is(:hover, :focus-visible) {
     
background: var(--theme-hover);
     
color: var(--ontheme);

     
& > svg {
       
stroke: currentColor;
       
fill: none;
     
}
   
}

   
&:active {
     
background: var(--theme-active);
   
}
 
}

 
& > button {
   
border-radius: var(--radius) 0 0 var(--radius);

   
@supports (border-start-start-radius: 1px) {
     
border-end-start-radius: var(--radius);
     
border-start-start-radius: var(--radius);
   
}
 
}

 
@media (--light) {
   
& > button,
   
& button:is(:focus-visible, :hover) {
     
text-shadow: 0 1px 0 var(--theme-active);
   
}
   
& > .gui-popup-button > svg,
   
& button:is(:focus-visible, :hover) > svg {
     
filter: drop-shadow(0 1px 0 var(--theme-active));
   
}
 
}
 
 
& svg {
   
inline-size: 2ch;
   
box-sizing: content-box;
   
stroke-linecap: round;
   
stroke-linejoin: round;
   
stroke-width: 2px;
 
}
}

.gui-popup-button {
 
inline-size: 4ch;
 
cursor: pointer;
 
position: relative;
 
display: inline-flex;
 
align-items: center;
 
justify-content: center;
 
border-inline-start: var(--border);
 
border-radius: 0 var(--radius) var(--radius) 0;

 
@supports (border-start-start-radius: 1px) {
   
border-inline-start: var(--border);
   
border-start-end-radius: var(--radius);
   
border-end-end-radius: var(--radius);
 
}

 
&:is(:hover,:focus-within) {
   
background: var(--theme-hover);
 
}

 
/* fixes iOS trying to be helpful */
 
&:focus {
   
outline: none;
 
}

 
&:active {
   
background: var(--theme-active);
 
}
 
 
&:focus-within {
   
& > svg {
     
transition-duration: var(--in-speed);
     
transform: rotateZ(.5turn);
   
}
   
& > .gui-popup {
     
transition-duration: var(--in-speed);
     
opacity: 1;
     
transform: translateY(0);
     
pointer-events: auto;
   
}
 
}

 
@media (--motionOK) {
   
& > svg {
     
transition: transform var(--out-speed) ease;
   
}
   
& > .gui-popup {
     
transform: translateY(5px);

     
transition:
        opacity var
(--out-speed) ease,
        transform var
(--out-speed) ease;
   
}
 
}
}

.gui-popup {
 
--shadow: 220 70% 15%;
 
--shadow-strength: 1%;

 
opacity: 0;
 
pointer-events: none;

 
position: absolute;
 
inset-block-end: 80%;
 
inset-inline-start: -1.5ch;
 
 
list-style-type: none;
 
background: var(--popupbg);
 
color: var(--theme-text);
 
padding-inline: 0;
 
padding-block: .5ch;
 
border-radius: var(--radius);
 
overflow: hidden;
 
display: flex;
 
flex-direction: column;
 
font-size: .9em;
 
transition: opacity var(--out-speed) ease;

 
box-shadow:
   
0 -2px 5px 0 hsl(var(--shadow) / calc(var(--shadow-strength) + 5%)),
   
0 1px 1px -2px hsl(var(--shadow) / calc(var(--shadow-strength) + 10%)),
   
0 2px 2px -2px hsl(var(--shadow) / calc(var(--shadow-strength) + 12%)),
   
0 5px 5px -2px hsl(var(--shadow) / calc(var(--shadow-strength) + 13%)),
   
0 9px 9px -2px hsl(var(--shadow) / calc(var(--shadow-strength) + 14%)),
   
0 16px 16px -2px hsl(var(--shadow) / calc(var(--shadow-strength) + 20%))
 
;

 
/* fixes iOS trying to be helpful */
 
&:focus {outline: none}

 
@media (--dark) {
   
--shadow-strength: 5%;
   
--shadow: 220 3% 2%;

   
& button:not(:focus-visible, :hover) {
     
text-shadow: 0 1px 0 var(--ontheme);
   
}

   
& button:not(:focus-visible, :hover) > svg {
     
filter: drop-shadow(0 1px 0 var(--ontheme));
   
}
 
}

 
@media (width <= 400px) {
   
inset-inline-start: -200%;
 
}

 
& svg {
   
fill: var(--popupbg);
   
stroke: var(--theme);

   
@media (prefers-color-scheme: dark) {
     
stroke: var(--theme-border);
   
}
 
}

 
& button {
   
color: var(--theme-text);
   
width: 100%;
 
}
}
       

       
import $ from 'blingblingjs'
import {rovingIndex} from 'roving-ux'

const splitButtons = $('.gui-split-button')
const popupButtons = $('.gui-popup-button')

// popup activating roving index for it's buttons
popupButtons
.forEach(element =>
  rovingIndex
({
    element
,
    target
: 'button',
 
}))

// support escape key
popupButtons
.on('keyup', e => {
 
if (e.code === 'Escape')
    e
.target.blur()
})

popupButtons
.on('focusin', e => {
  e
.currentTarget.setAttribute('aria-expanded', true)
})

popupButtons
.on('focusout', e => {
  e
.currentTarget.setAttribute('aria-expanded', false)
})

// respond to any button interaction
splitButtons
.on('click', event => {
 
if (event.target.nodeName !== 'BUTTON') return
  console
.info(event.target.innerText)
})