Skip to content

Commit

Permalink
enh(NcActionButton): Fully support sematic roles for pressed and chec…
Browse files Browse the repository at this point in the history
…ked states

* This introduces new props `modelValue`, `modelBehavior` and `radioValue`

Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
  • Loading branch information
susnux committed Jan 20, 2024
1 parent fc49db8 commit 36f1311
Show file tree
Hide file tree
Showing 4 changed files with 181 additions and 28 deletions.
179 changes: 161 additions & 18 deletions src/components/NcActionButton/NcActionButton.vue
Original file line number Diff line number Diff line change
Expand Up @@ -171,18 +171,51 @@ export default {
</script>
```

You can set an "pressed" state, for example you have a toggle button, in this example the "fullscreen" button:
### With different model behavior
By default the button will act like a normal button, but it is also possible to change the behavior to a toggle button, checkbox button or radio button.

For example to have the button act like a toggle button just set the `modelValue` property to the toggle state:

```vue
<template>
<NcActions>
<NcActionButton :model-value.sync="fullscreen">
<template #icon>
<Fullscreen :size="20" />
</template>
Fullscreen
</NcActionButton>
</NcActions>
</template>
<script>
import Fullscreen from 'vue-material-design-icons/Fullscreen.vue'
export default {
components: {
Fullscreen,
},
data() {
return {
fullscreen: true,
}
},
}
</script>
```

Another example would be using it with checkbox semantics, to enable or disable features.
This also allows tri-state behavior (`true`, `false`, `null`) in which case `aria-checked` will be either `true`, `false` or `mixed`.

```vue
<template>
<NcActions>
<NcActionButton :pressed="handRaised" @click="handRaised = !handRaised">
<NcActionButton :model-value.sync="handRaised" model-behavior="checkbox">
<template #icon>
<HandBackLeft :size="20" />
</template>
Raise hand
</NcActionButton>
<NcActionButton :pressed="fullscreen" @click="fullscreen = !fullscreen">
<NcActionButton :model-value.sync="fullscreen" model-behavior="checkbox">
<template #icon>
<Fullscreen :size="20" />
</template>
Expand All @@ -208,21 +241,56 @@ export default {
}
</script>
```

It is also possible to use the button with radio semantics, this is only possible in menus and not for inline actions!

```vue
<template>
<NcActions>
<NcActionButton :model-value.sync="payment" model-behavior="radio" value="cash">
<template #icon>
<Cash :size="20" />
</template>
Pay with cash
</NcActionButton>
<NcActionButton :model-value.sync="payment" model-behavior="radio" value="card">
<template #icon>
<CreditCard :size="20" />
</template>
Pay by card
</NcActionButton>
</NcActions>
</template>
<script>
import Cash from 'vue-material-design-icons/Cash.vue'
import CreditCard from 'vue-material-design-icons/CreditCard.vue'
export default {
components: {
Cash,
CreditCard,
},
data() {
return {
payment: 'card',
}
},
}
</script>
```
</docs>

<template>
<li class="action" :class="{ 'action--disabled': disabled }" :role="isInSemanticMenu && 'presentation'">
<button class="action-button button-vue"
:class="{
'action-button--active': pressed,
<button :aria-label="ariaLabel"
:class="['action-button button-vue', {
'action-button--active': isChecked,
focusable: isFocusable,
}"
:aria-label="ariaLabel"
:aria-pressed="active"
}]"
:title="title"
:role="isInSemanticMenu && 'menuitem'"
type="button"
@click="onClick">
v-bind="buttonAttributes"
@click="handleClick">
<!-- @slot Manually provide icon -->
<slot name="icon">
<span :class="[isIconUrl ? 'action-button__icon--url' : icon]"
Expand Down Expand Up @@ -255,8 +323,8 @@ export default {

<!-- right arrow icon when there is a sub-menu -->
<ChevronRightIcon v-if="isMenu" :size="20" class="action-button__menu-icon" />
<CheckIcon v-else-if="pressed === true" :size="20" class="action-button__pressed-icon" />
<span v-else-if="pressed === false" class="action-button__pressed-icon material-design-icon" />
<CheckIcon v-else-if="isChecked === true" :size="20" class="action-button__pressed-icon" />
<span v-else-if="isChecked === false" class="action-button__pressed-icon material-design-icon" />

<!-- fake slot to gather inner text -->
<slot v-if="false" />
Expand Down Expand Up @@ -294,7 +362,7 @@ export default {
* @todo Add a check in @nextcloud/vue 9 that this prop is not provided,
* otherwise root element will inherit incorrect aria-hidden.
*/
ariaHidden: {
ariaHidden: {
type: Boolean,
default: null,
},
Expand All @@ -317,11 +385,30 @@ export default {
},
/**
* The pressed state of the button if it has a checked state
* This will add the `aria-pressed` attribute and for the button to have a primary border in checked state.
* The button's behavior, by default the button acts like a normal button with optional toggle button behavior if `modelValue` is `true` or `false`
* But you can also set to checkbox button behavior with tri-state or radio button like behavior.
*/
pressed: {
type: Boolean,
modelBehavior: {
type: String,
default: 'button',
validator: (behavior) => ['checkbox', 'radio', 'button'].includes(behavior),
},
/**
* The buttons state if `modelBehavior` is 'checkbox' or 'radio' (meaning if it is pressed / selected)
* Either boolean for checkbox and toggle button behavior or `value` for radio behavior
*/
modelValue: {
type: [Boolean, String],
default: null,
},
/**
* The value used for the `modelValue` when this component is used with radio behavior
* Similar to the `value` attribute of `<input type="radio">`
*/
value: {
type: String,
default: null,
},
},
Expand All @@ -335,6 +422,62 @@ export default {
isFocusable() {
return !this.disabled
},
/**
* The current "checked" or "pressed" state for the model behavior
*/
isChecked() {
if (this.modelBehavior === 'radio') {
return this.modelValue === this.value
}
return this.modelValue
},
/**
* HTML attributes to bind to the <button>
*/
buttonAttributes() {
const attributes = {}
if (this.isInSemanticMenu) {
// By default it needs to be a menu item in semantic menus
attributes.role = 'menuitem'
if (this.modelBehavior === 'radio') {
attributes.role = 'menuitemradio'
attributes['aria-checked'] = this.isChecked ? 'true' : 'false'
} else if (this.modelBehavior === 'checkbox' || this.modelValue !== null) {
// either if checkbox behavior was set or the model value is not unset
attributes.role = 'menuitemcheckbox'
attributes['aria-checked'] = this.modelValue === null ? 'mixed' : (this.modelValue ? 'true' : 'false')
}
} else if (this.modelValue !== null) {
// In case this has a modelValue it is considered a toggle button, so we need to set the aria-pressed
attributes['aria-pressed'] = this.modelValue ? 'true' : 'false'
}
return attributes
},
},
methods: {
/**
* Forward click event, let mixin handle the close-after-click and emit new modelValue if needed
* @param {MouseEvent} event The click event
*/
handleClick(event) {
this.onClick(event)
// If modelValue or modelBehavior is set (so modelValue might be null for tri-state) we need to update it
if (this.modelValue !== null || this.modelBehavior !== 'button') {
if (this.modelBehavior === 'radio') {
if (!this.isChecked) {
this.$emit('update:modelValue', this.value)
}
} else {
this.$emit('update:modelValue', !this.isChecked)
}
}
},
},
}
</script>
Expand Down
15 changes: 9 additions & 6 deletions src/components/NcActionButtonGroup/NcActionButtonGroup.vue
Original file line number Diff line number Diff line change
Expand Up @@ -29,22 +29,25 @@ This should be used sparingly for accessibility.
<NcActions>
<NcActionButtonGroup name="Text alignment">
<NcActionButton aria-label="Align left"
:pressed="alignment === 'l'"
@click="alignment = 'l'">
:model-value.sync="alignment"
model-behavior="radio"
value="l">
<template #icon>
<AlignLeft :size="20" />
</template>
</NcActionButton>
<NcActionButton aria-label="Align center"
:pressed="alignment === 'c'"
@click="alignment = 'c'">
:model-value.sync="alignment"
model-behavior="radio"
value="c">
<template #icon>
<AlignCenter :size="20" />
</template>
</NcActionButton>
<NcActionButton aria-label="Align right"
:pressed="alignment === 'r'"
@click="alignment = 'r'">
:model-value.sync="alignment"
model-behavior="radio"
value="r">
<template #icon>
<AlignRight :size="20" />
</template>
Expand Down
10 changes: 9 additions & 1 deletion src/components/NcActions/NcActions.vue
Original file line number Diff line number Diff line change
Expand Up @@ -1303,6 +1303,11 @@ export default {
title = text
}
const propsToForward = { ...(action?.componentOptions?.propsData ?? {}) }
// not available on NcButton
delete propsToForward.modelValue
delete propsToForward.modelBehavior
return h('NcButton',
{
class: [
Expand All @@ -1320,11 +1325,14 @@ export default {
// If it has a menuName, we use a secondary button
type: this.type || (buttonText ? 'secondary' : 'tertiary'),
disabled: this.disabled || action?.componentOptions?.propsData?.disabled,
...action?.componentOptions?.propsData,
pressed: action?.componentOptions?.propsData?.modelValue,
...propsToForward,
},
on: {
focus: this.onFocus,
blur: this.onBlur,
// forward any pressed state from NcButton just like NcActionButton does
'update:pressed': action?.componentOptions?.listeners?.['update:modelValue'] ?? (() => {}),
// If we have a click listener,
// we bind it to execute on click and forward the click event
...(!!clickListener && {
Expand Down
5 changes: 2 additions & 3 deletions tests/unit/components/NcActions/NcActions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,17 +139,16 @@ describe('NcActions.vue', () => {
})
})
it('shows the first action outside.', () => {
expect(wrapper.findAll('.action-item').length).toBe(2)
expect(wrapper.findAll('button.action-item').length).toBe(1)
expect(wrapper.find('button.action-item').exists()).toBe(true)
expect(wrapper.find('button.action-item[aria-label="Test1"]').exists()).toBe(true)
})
it('shows the menu toggle.', () => {
expect(wrapper.find('.action-item__menutoggle').exists()).toBe(true)
})
it('shows the first two action outside on prop change.', async () => {
await wrapper.setProps({ inline: 2 })
expect(wrapper.findAll('.action-item').length).toBe(3)
expect(wrapper.findAll('button.action-item').length).toBe(2)
expect(wrapper.find('.action-item__menutoggle').exists()).toBe(true)
})
it('shows all actions outside on prop change.', async () => {
await wrapper.setProps({ inline: 3 })
Expand Down

0 comments on commit 36f1311

Please sign in to comment.