-
Notifications
You must be signed in to change notification settings - Fork 8
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(split-button): add split button
- Loading branch information
1 parent
b9a7fa7
commit 2365621
Showing
9 changed files
with
1,019 additions
and
190 deletions.
There are no files selected for viewing
667 changes: 498 additions & 169 deletions
667
packages/documentation/pages/usage/components/button.vue
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
215 changes: 215 additions & 0 deletions
215
packages/kotti-ui/source/kotti-button/KtSplitButton.vue
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,215 @@ | ||
<template> | ||
<div class="kt-split-button"> | ||
<KtButton | ||
class="kt-split-button__primary-action" | ||
v-bind="primaryActionButtonProps" | ||
@click="onClickPrimaryAction" | ||
/> | ||
<div ref="tippyTriggerRef"> | ||
<KtButton | ||
:class="secondaryActionsButtonClasses" | ||
v-bind="secondaryActionsButtonProps" | ||
@click.stop="onClickSecondaryActions" | ||
/> | ||
</div> | ||
<div ref="tippyContentRef"> | ||
<ActionsItem | ||
v-for="(action, index) in actions" | ||
:key="index" | ||
v-bind="{ | ||
...action, | ||
hasFocus: isActionInFocus(index), | ||
index, | ||
rootDataTest: dataTest, | ||
}" | ||
@click="onClickAction" | ||
@updateIndexInFocus="onUpdateIndexInFocus" | ||
/> | ||
</div> | ||
</div> | ||
</template> | ||
|
||
<script lang="ts"> | ||
import { Yoco } from '@3yourmind/yoco' | ||
import omit from 'lodash/omit' | ||
import { | ||
computed, | ||
defineComponent, | ||
onBeforeUnmount, | ||
onMounted, | ||
ref, | ||
watch, | ||
} from 'vue' | ||
import { makeProps } from '../make-props' | ||
import ActionsItem from './components/ActionsItem.vue' | ||
import { useActionsTippy } from './hooks/use-actions-tippy' | ||
import { KottiSplitButton } from './types' | ||
/** | ||
* Do not change. UNSET_INDEX is expected to be -1. | ||
*/ | ||
const UNSET_INDEX = -1 | ||
export default defineComponent({ | ||
name: 'KtSplitButton', | ||
components: { | ||
ActionsItem, | ||
}, | ||
props: makeProps(KottiSplitButton.propsSchema), | ||
setup(props, { emit }) { | ||
const { isTippyOpen, setIsTippyOpen, tippyContentRef, tippyTriggerRef } = | ||
useActionsTippy({ | ||
isDisabled: computed(() => props.isDisabled), | ||
isLoading: computed(() => props.isLoading), | ||
}) | ||
const currentActionIndexInFocus = ref<number>(UNSET_INDEX) | ||
const hasAvailableActions = computed(() => | ||
props.actions.some((action) => !action.isDisabled), | ||
) | ||
const getNextAvailableActionIndex = ( | ||
currentIndex: number, | ||
direction: 'down' | 'up', | ||
) => { | ||
if ( | ||
(currentIndex === 0 && direction === 'up') || | ||
(currentIndex === props.actions.length - 1 && direction === 'down') | ||
) | ||
return currentIndex | ||
const step = direction === 'down' ? 1 : -1 | ||
for ( | ||
let nextIndex = currentIndex + step; | ||
nextIndex < props.actions.length && nextIndex >= 0; | ||
nextIndex += step | ||
) { | ||
const action = props.actions[nextIndex] | ||
if (!action) | ||
throw new Error( | ||
`KtSplitButton: action item not found for index: ${nextIndex}`, | ||
) | ||
if (!action.isDisabled) return nextIndex | ||
} | ||
return currentIndex | ||
} | ||
const keydownListener = (event: KeyboardEvent) => { | ||
if (!hasAvailableActions.value) return | ||
switch (event.key) { | ||
case 'ArrowDown': { | ||
currentActionIndexInFocus.value = getNextAvailableActionIndex( | ||
currentActionIndexInFocus.value, | ||
'down', | ||
) | ||
break | ||
} | ||
case 'ArrowUp': { | ||
currentActionIndexInFocus.value = getNextAvailableActionIndex( | ||
currentActionIndexInFocus.value, | ||
'up', | ||
) | ||
break | ||
} | ||
} | ||
} | ||
onMounted(() => { | ||
tippyContentRef.value?.addEventListener('keydown', keydownListener, { | ||
capture: true, | ||
}) | ||
}) | ||
onBeforeUnmount(() => { | ||
tippyContentRef.value?.removeEventListener('keydown', keydownListener) | ||
}) | ||
watch( | ||
() => isTippyOpen.value, | ||
(isOpen) => { | ||
if (!isOpen) currentActionIndexInFocus.value = UNSET_INDEX | ||
else if (hasAvailableActions.value) | ||
// Focus the 1st available action item | ||
currentActionIndexInFocus.value = getNextAvailableActionIndex( | ||
UNSET_INDEX, | ||
'down', | ||
) | ||
}, | ||
) | ||
return { | ||
currentActionIndexInFocus, | ||
isActionInFocus: (actionIndex: number) => | ||
actionIndex === currentActionIndexInFocus.value, | ||
onClickAction: () => setIsTippyOpen(false), | ||
onClickPrimaryAction: () => { | ||
if (props.isDisabled || props.isLoading) return | ||
emit('click') | ||
}, | ||
onClickSecondaryActions: () => setIsTippyOpen(!isTippyOpen.value), | ||
onUpdateIndexInFocus: (index: number) => { | ||
if (isTippyOpen.value) currentActionIndexInFocus.value = index | ||
}, | ||
primaryActionButtonProps: computed(() => ({ | ||
...omit(props, ['actions', 'dataTest', 'isDisabled']), | ||
'data-test': [props.dataTest, 'primary-action'] | ||
.filter(Boolean) | ||
.join('.'), | ||
disabled: props.isDisabled, | ||
})), | ||
secondaryActionsButtonClasses: computed(() => ({ | ||
'kt-split-button__secondary-actions': true, | ||
'kt-split-button__secondary-actions--is-active': isTippyOpen.value, | ||
})), | ||
secondaryActionsButtonProps: computed(() => ({ | ||
'data-test': [props.dataTest, 'secondary-actions'] | ||
.filter(Boolean) | ||
.join('.'), | ||
disabled: props.isDisabled, | ||
icon: Yoco.Icon.CHEVRON_DOWN, | ||
size: props.size, | ||
type: props.type, | ||
})), | ||
tippyContentRef, | ||
tippyTriggerRef, | ||
} | ||
}, | ||
}) | ||
</script> | ||
|
||
<style lang="scss" scoped> | ||
.kt-split-button { | ||
display: -webkit-inline-box; | ||
&__primary-action { | ||
border-radius: var(--border-radius) 0 0 var(--border-radius); | ||
} | ||
&__secondary-actions { | ||
width: var(--unit-6); | ||
border-left: 0; | ||
border-radius: 0 var(--border-radius) var(--border-radius) 0; | ||
&--is-active { | ||
&.kt-button--type-default:not(:hover) { | ||
background-color: var(--primary-20); | ||
border-color: var(--primary-20); | ||
} | ||
&.kt-button--type-primary:not(:hover) { | ||
background-color: var(--primary-80); | ||
border-color: var(--primary-80); | ||
} | ||
} | ||
} | ||
} | ||
</style> |
136 changes: 136 additions & 0 deletions
136
packages/kotti-ui/source/kotti-button/components/ActionsItem.vue
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,136 @@ | ||
<template> | ||
<button | ||
ref="itemRef" | ||
v-bind="itemProps" | ||
@click.stop="onItemClick" | ||
@keydown.down.prevent | ||
@keydown.up.prevent | ||
> | ||
<i v-if="icon" class="yoco" v-text="icon" /> | ||
<span v-text="label" /> | ||
</button> | ||
</template> | ||
|
||
<script lang="ts"> | ||
import { | ||
computed, | ||
defineComponent, | ||
onMounted, | ||
onBeforeUnmount, | ||
ref, | ||
watch, | ||
} from 'vue' | ||
import { z } from 'zod' | ||
import { makeProps } from '../../make-props' | ||
import { isInFocus } from '../../utilities' | ||
import { KottiSplitButton } from '../types' | ||
const propsSchema = KottiSplitButton.actionSchema.extend({ | ||
hasFocus: z.boolean().default(false), | ||
index: z.number(), | ||
rootDataTest: z.string().nullable().default(null), | ||
}) | ||
export default defineComponent({ | ||
name: 'SplitButtonActionsItem', | ||
props: makeProps(propsSchema), | ||
setup(props, { emit }) { | ||
const itemRef = ref<HTMLButtonElement | null>(null) | ||
const focusListener = () => { | ||
if (isInFocus(itemRef.value) && !props.hasFocus) | ||
emit('updateIndexInFocus', props.index) | ||
} | ||
onMounted(() => | ||
itemRef.value?.addEventListener('focus', focusListener, { | ||
capture: true, | ||
}), | ||
) | ||
onBeforeUnmount(() => | ||
itemRef.value?.removeEventListener('focus', focusListener), | ||
) | ||
watch( | ||
() => props.hasFocus, | ||
(hasFocus, hadFocus) => { | ||
if (hasFocus && !hadFocus) itemRef.value?.focus({ preventScroll: true }) | ||
}, | ||
) | ||
return { | ||
itemProps: computed(() => ({ | ||
class: { | ||
'kt-split-button-actions-item': true, | ||
'kt-split-button-actions-item--is-disabled': props.isDisabled, | ||
}, | ||
'data-test': [ | ||
props.rootDataTest, | ||
`action-${props.index}`, | ||
props.dataTest, | ||
] | ||
.filter(Boolean) | ||
.join('.'), | ||
disabled: props.isDisabled, | ||
})), | ||
itemRef, | ||
onItemClick: () => { | ||
if (props.isDisabled) return | ||
emit('click') | ||
props.onClick() | ||
}, | ||
} | ||
}, | ||
}) | ||
</script> | ||
|
||
<style lang="scss" scoped> | ||
// Remove default styles from <button /> elements | ||
@mixin remove-button-styles { | ||
padding: 0; | ||
margin: 0; | ||
font: inherit; | ||
text-align: inherit; | ||
appearance: none; | ||
background-color: transparent; | ||
border: none; | ||
border-radius: 0; | ||
} | ||
.kt-split-button-actions-item { | ||
@include remove-button-styles; | ||
display: flex; | ||
gap: var(--unit-2); | ||
align-items: center; | ||
width: 100%; | ||
padding: var(--unit-2) var(--unit-4); | ||
color: var(--text-01); | ||
cursor: pointer; | ||
&--is-disabled { | ||
cursor: not-allowed; | ||
opacity: 0.46; | ||
} | ||
&:focus { | ||
outline: none; | ||
} | ||
&:focus, | ||
&:hover:not(.kt-split-button-actions-item--is-disabled) { | ||
background-color: var(--ui-01); | ||
} | ||
.yoco { | ||
font-size: 1rem; | ||
} | ||
span { | ||
font-size: 16px; | ||
line-height: 20px; | ||
} | ||
} | ||
</style> |
Oops, something went wrong.