Skip to content

Commit

Permalink
feat(split-button): add split button
Browse files Browse the repository at this point in the history
  • Loading branch information
santiagoballadares committed Mar 15, 2024
1 parent c03cf8a commit 2517152
Show file tree
Hide file tree
Showing 9 changed files with 1,019 additions and 190 deletions.
667 changes: 498 additions & 169 deletions packages/documentation/pages/usage/components/button.vue

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions packages/kotti-ui/source/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,5 @@ export const ISO8601_SECONDS = 'YYYY-MM-DD HH:mm:ss' as const
export const ONE_HUNDRED_PERCENT = 100 as const

export const TIPPY_LIGHT_BORDER_ARROW_HEIGHT = 7

export const TIPPY_VERTICAL_OFFSET = 4
3 changes: 2 additions & 1 deletion packages/kotti-ui/source/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { KtBanner } from './kotti-banner'
export * from './kotti-banner'
import { KtBreadcrumb } from './kotti-breadcrumb'
export * from './kotti-breadcrumb'
import { KtButton } from './kotti-button'
import { KtButton, KtSplitButton } from './kotti-button'
export * from './kotti-button'
import { KtButtonGroup } from './kotti-button-group'
export * from './kotti-button-group'
Expand Down Expand Up @@ -157,6 +157,7 @@ export default {
KtPagination,
KtPopover,
KtRow,
KtSplitButton,
KtTable,
KtTableColumn,
KtTableConsumer,
Expand Down
215 changes: 215 additions & 0 deletions packages/kotti-ui/source/kotti-button/KtSplitButton.vue
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 packages/kotti-ui/source/kotti-button/components/ActionsItem.vue
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>
Loading

0 comments on commit 2517152

Please sign in to comment.