Is it possible to create a "split button" in MainUI/Framework7?

I’m dabbling with how it could be possible to make add-ons upgradable. As a part of that, my most “obvious” idea was to make the “install/add/remove” button on the add-ons a “split button” where you could have more options like upgrade, downgrade etc.

I find very few examples for how to do things with Framework7 online, and I’ve not found anything that resembles a “split button”. Can it be done, and if so how?

Example of a “split button”:
images

Alternatively, does anybody have other ideas for how to make more choices available without radically changing the layout?

You could create it with an f7-segmented control.

If it were a custom widget in MainUI, I rebuilt it like that (just to show the concept):
image

    - component: f7-segmented
      config:
        round: true
        bgColor: blue
        color: white
        style:
          width: 150px
      slots:
        default:
          - component: oh-button
            config:
              text: Open 
              style:
                width: 70%
                border-right: 3px solid white
          - component: oh-button
            config:
              text: ...
              style:
                width: 30%

Thanks, I’ll try to study that some more. I did read about “segmented” in F7’s docs, and got the impression that they always had to be the same width, which is why I though that was a dead end. But, if you can easily set the width like that, it seems like a viable approach.

The f7 docs do say

Within the control, all buttons are equal in width.

but this is misleading. I assume it means to say “equal in width, by default” because, as Oliver demonstrated, it is quite possible to adjust the width of the button independently.

You also don’t really need to use the segmented it’s just a container with some built-in settings for more easily applying consistent formatting to the buttons. If you want to just put two buttons in a div with a little bit of styling, you get the same effect:

<div style="display: flex; width: 200px; height: 30px;">
  <a href="#" class="button button-fill bg-color-blue" style="border-radius: 30px 0px 0px 30px; flex: 6 6 0%;"><span>Open</span></a>
  <a href="#" class="button button-fill bg-color-blue" style="border-radius: 0px 30px 30px 0px; margin-left: 2px; flex: 1 1 0%;"><span>...</span></a>
</div>

image

In a vue file that version would look something like this:

<div style="display: flex; width: 200px; height: 30px;">
  <f7-button @click="clickFunctionHere" text="Open" :fill="true" bgColor="blue" style="border-radius: 30px 0 0 30px; flex: 6 6 0%;" />
  <f7-button @click="menuFunctionHere" text="..." :fill="true" bgColor="blue" style="border-radius: 0px 30px 30px 0px; margin-left: 2px; flex: 1 1 0%;" />
</div>
1 Like

Both options looks good. There is so much I don’t know about how this works, and I’d prefer to “hardcode” as few values as possible, and instead lean on F7 to handle scaling height, width, font sizes etc. Is that a reason to stick with “segmented”, or would I easily achieve the same by just applying some classes on the div? In that case I’d have to figure out which classes I guess, “segmented” still looks like it might be easier to get to behave…?

“Easy” is a relative thing in this case. The segmented is certainly the f7 native way of doing it, and the f7 library always tries to make things as “easy” as possible. On the other hand, you may find that getting some piece of the button just the way you like is harder because of the extra built-in styling.

If I were doing it, I’d probably start with the segmented and then move on from there if I didn’t like the result.

That’s very much what I’ve been thinking. I’ve gotten it to look what I consider “reasonable” already. It’s very much uncertain if this will ever become something that is used, I’m kind of trying to make a “working demo”. If it turns out to be used, somebody more knowledgeable should probably look at the visual aspect.

I’m experiencing some strangeness regarding the width though. It doesn’t seem like the total is 100%, I can give one button 100% and the other 30%, and it seems to somehow treat that merely as a “ratio” and divide the space between them 10:3. This might be basic browser behavior for all I know. Regardless, the point is that the left button can have different width depending on what text it has, and I came to the conclusion that a percentage/ratio isn’t really suitable, because the right button doesn’t need to become wider if the word is longer. So, I tried using pixels (even though CSS pixels aren’t pixels) on the right button, in the hope that the right button would be of constant size, but it still seems to vary somewhat.

It would be much easier if I knew the html/css stuff better, but, I think what I have now will have to do for now.

I’m curious. How can you upgrade or downgrade an add-on? Is this only for marketplace add-ons?

Yes. It’s related to this “megathread”: Marketplace versioning with embedded resource

The idea is that it should be possible to offer multiple versions. It would then be possible to be notified when a new “stable” version is available, or to manually upgrade to “development versions”. If something doesn’t work, one could also downgrade.

The “split button” would only be displayed for add-ons where multiple versions exist that is marked as compatible with your version of OH.

1 Like

The button itself is working fine, but I have a problem with the associated popover (when using the “right part” of the button).

I understand that I’m doing something wrong, I just can’t figure out what: I define the <f7-popover> element in a template, so that it ends up somewhere down in the hierarchy in the DOM, with display: none. But, when it’s opened, a new element is inserted under the framework7-root DIV, which is displayed. However, when this is closed, the created element isn’t removed, it’s opacity is merely set to 0. That means that it’s still there, and blocks clicks to the elements which it covered while it was “open”.

Even worse, a new such element is created each time the popover is opened. I realize that there must be something wrong with the way I close the popover, I just can’t figure out how to do it properly. I have made the popover close when an option is selected, and it’s this “closing” that doesn’t clean up properly. If I click the “backdrop” instead, the element is removed and everything is fine.

There are two vue templates involved in this. Please not that this is a work in progress, so don’t look at the stuff that’s not related to opening/closing, as some things are just there because of testing and some things aren’t made yet.

<template>
  <div class="addon-install-button">
    <f7-segmented v-if="versioned" class="split-button" round :bgColor="buttonColor">
      <f7-button class="install-button" :text="buttonText" small @click="clicked()" />
      <f7-button class="install-menu-button" popover-open=".addon-version-select" icon-f7="chevron_down" small />
    </f7-segmented>
    <f7-button v-else class="install-button" :text="buttonText" :color="buttonColor" round small fill @click="clicked()" />
    <addon-version-select @version-selected="(v) => versionSelected(v)" :addon="addon" />
  </div>
</template>

<style lang="stylus">
.addon-install-button
  .split-button
    .install-button
      --f7-button-text-color var(--f7-page-bg-color)
      --f7-button-text-transform uppercase
      padding-left 15px
      padding-right 10px
      font-size 16px
      text-overflow: clip
    .install-menu-button
      --f7-button-text-color var(--f7-page-bg-color)
      padding-left 7px
      padding-right 16px
      text-overflow: clip
      border-left-width: thin
      border-left-color: var(--f7-page-bg-color)
      width 40px
  .install-button
    --f7-button-text-transform uppercase
    padding-left 15px
    padding-right 15px
    font-size 16px
    text-overflow: clip
</style>

<script>

import AddonVersionSelect from '@/components/addons/addon-version-select.vue'

export default {
  props: ['addon'],
  components: {
    AddonVersionSelect
  },
  emits: ['clicked', 'install', 'uninstall', 'upgrade', 'downgrade'], // TODO: (Nad) Figure out emits
  computed: {
    versioned () {
      return this.addon && this.addon.versions && Object.keys(this.addon.versions).length > 1
    },
    installable () {
      return (this.addon && (this.addon.contentType === 'application/vnd.openhab.bundle' || this.addon.contentType.indexOf('application/vnd.openhab.feature') === 0))
    },
    buttonColor () {
      if (!this.addon) {
        return 'gray'
      }
      if (this.addon.installed) {
        // TODO: (Nad) Upgrade/downgrade
        return 'red'
      }
      return 'blue'
    },
    buttonText () {
      if (!this.addon) {
        return ''
      }
      if (this.addon.installed) {
        // TODO: (Nad) Upgrade/downgrade
        return 'Remove'
      }
      return this.installable ? 'Install' : 'Add'
    } // TODO: (Nad) Clean up not needed
  },
  methods: {
    clicked () {
      this.$emit('clicked') // TODO: (Nad) Emit actual event
    },
    versionSelected (version) {
      this.$emit('version-selected', version)
    }
  }
}
</script>
<template>
  <f7-popover ref="versionPopover" class="addon-version-select" closeByBackdropClick closeByOutsideClick closeOnEscape>
    <div class="block-title">
      Select version
    </div>
    <div class="list">
      <ul>
        <li v-for="version in versions" :key="version.name">
          <label class="item-radio item-content" @click="versionSelected(version)">
            <input type="radio" name="version-select" :value="version.name" :checked="version.selected">
            <i class="icon icon-radio" />
            <div class="item-inner" :title="versionTooltip(version)">
              <div class="item-title" :class="versionClasses(version)">{{ version.name }}</div>
            </div>
          </label>
        </li>
      </ul>
    </div>
  </f7-popover>
</template>

<style lang="stylus">
.addon-version-select
  border-radius: 20px;
  padding: 0px 10px 10px 10px;
  .block-title
    font-weight: 700;
  .item-title.incompatible:before
    content: 'exclamationmark_triangle_fill'
    font-family: 'Framework7 Icons'
    font-style: normal
    font-weight: 400
    color: red
    margin-inline-end: 0.3em
  .item-title.latest:after
    content: 'checkmark_shield_fill'
    font-family: 'Framework7 Icons'
    font-style: normal
    font-weight: 400
    margin-inline-start: 0.3em
    color: green
</style>

<script>

export default {
  props: ['addon'],
  emits: ['versionSelected'],
  computed: {
    versions () {
      if (!this.addon || !this.addon.versions) {
        return []
      }
      let result = Object.keys(this.addon.versions).flatMap((k) => {
        let result = {
          name: this.addon.versions[k].version,
          compatible: this.addon.versions[k].compatible,
          stable: this.addon.versions[k].stable
        }
        if (!this.addon.installedVersion && this.addon.versions[k].version === this.addon.version) {
          result.selected = true
        }
        if ((this.addon.installedVersion && this.addon.versions[k].version === this.addon.installedVersion)) {
          result.installed = true
        }
        if (this.addon.versions[k].version === this.addon.defaultVersion) {
          result.latest = true
        }
        return result
      })
      if (result.length > 0 && !result.some(v => v.selected)) {
        let ver = result.find((v) => v.version === this.addon.defaultVersion)
        if (ver) {
          ver.selected = true
        } else {
          result[0].selected = true
        }
      }
      return result
    }
  },
  methods: {
    versionClasses (version) {
      let result = []
      if (!version.compatible) {
        result.push('incompatible')
      }
      if (version.stable) {
        result.push('stable')
      }
      if (version.latest) {
        result.push('latest')
      }
      return result.join(' ')
    },
    versionTooltip (version) {
      return version.latest ? 'Latest stable' : (version.compatible ? undefined : 'Incompatible')
    },
    versionSelected (version) {
      this.$refs.versionPopover.close()
      this.$emit('version-selected', version)
    }
  }
}
</script>

If somebody wants to check it out and run it themselves, the current state can be found here:

It won’t work properly without the corresponding changes to Core (there are changes to the REST API), but it should probably work well enough using npm start that it would be possible to debug the popover with some tweaking. The “split button” isn’t shown if the add-on doesn’t have multiple versions, and standard Core REST doesn’t have “multiple versions”, so that check must be changed to use the “split button” regardless.

I think I know what happens now. When a selection is made in the popup, version-selected is emitted to the parent which makes the parent reload its content. In the meanwhile, the popover is doing its “closing animation” - so that when it’s finally done with the animation, the contents of the DOM has changed and it fails to complete the “hiding” of the correct element. In the meanwhile, a new identical element has appeared, and whatever action is to be taken when the animation finished is applied to that instead. Thus, the “old one” remains as an “orphaned element”.

I’m not sure how to solve it though, I probably need to wait for the animation to finish before emitting - but I’m not sure how. I guess I must use some event somehow.

It seems like I got it working now (by waiting for the animation to finish) with this commit:

I had to suppress an eslint because I set a variable in a “computed” method, so it’s probably considered a bit hacky - but seems to work.

I need to keep track of what the initial selection is, to avoid emitting (and thus reloading) if the same version that is selected is “selected again”, and since I figure this out in a “computed”, that’s bad behavior. So, the solution would probably have to be to not do this is “computed”, but it’s unclear to me exactly where it should be done. “Computed” fits nice in that it is only done once unless “this.addon” is changed (as far as I understand), which avoids doing unneccesary calculations. I guess that “this.addon” never actually changes (I don’t have the full overview of how Vue/F7 works, so it’s somewhat of an enigma to me exactly in what order things run), so that it could be calculated in some “load” method instead. But, I don’t know how to trigger a “load” method since this is a component, not a page (for which I have found examples of a “load” method) - and as I don’t have the full overview of what is invoked when, it’s not tempting to try to do just to get rid of the warning.

The reason for the warning is that “it’s confusing” when “computed has side effects”. To me, that’s not even remotely the most confusing about all this :wink: