2

Goal: I want to enrich a predefined component with my own behavior. This is typically the case with list, tables and trees, implementing my actions like "delete", "add before", "add after", "move up",... (with text field this seems to be simple...)

I thought there must be a way to attach key listeners at the component itself (assumed that there's something like a "focus"), e.g. if i have two trees on a page pressing "Ctrl+" will add one time an A to treeA via listenerA and the other a B to treeB via listenerB.

Adding an ajax listener at a tree node or the tree itself does not work. So it seems to be necessary (see two answers below) to catch key globally and "dispatch" them myself properly. At least with one tree this should work without hassle.

According to the answers below this can only be done using JavaScript or using a non standard JSF tag.

As i am concerned with JSF question at most 2 times a year, i think someone more involved can give insight in best practice on this twilight zone between JSF and JavaScript.

In this snippet i want to create a new child item when "+" is pressed.

<h:form>
    <p:tree id="document" value="#{demo.root}" var="node"
        selectionMode="single" selection="#{demo.selection}">
        <p:treeNode>
            <h:outputText value="#{node.label}" />
        </p:treeNode>
    </p:tree>
</h:form>

The tag

<f:ajax event="keypress" listener="#{demo.doTest}" />

is not accepted in "treeNode" and "tree" and has no function in "form".

= EDIT

As can be seen in the answers, this concrete scenario is supported by simply using <p:hotkey>. This solution has 2 drawbacks, its Primefaces bound and it fails if we add input components like this

<h:form>
    <p:tree id="document" value="#{demo.root}" var="node"
        selectionMode="single" selection="#{demo.selection}">
        <p:treeNode>
            <p:inputText value="#{node.label}" />
        </p:treeNode>
    </p:tree>
</h:form>

What is the best practice to implement such things? At least, is it possible in plain JSF at all? If i only use plain JSF, what would be the least ugly idiom.

= EDIT

I want to point to a short history of findings, given as an answer below, to give more detail on the problem behind this question

mtraut
  • 4,720
  • 3
  • 24
  • 33
  • Can you please clarify the question itself and keep concrete questions in there? You have posted two answers wherein you seem to have posted more (related) questions. But it isn't exactly clear what's ultimately actually being asked. – BalusC Jan 09 '15 at 12:39
  • @BalusC - Restated question - does this make more sense now? – mtraut Jan 09 '15 at 21:05

4 Answers4

3

This implementation enables navigation and add/remove as well.

IMHO it has the best functionality/effort ratio.

I don't know what you mean with standard JSF tag or plain JSF, but in this example there isn't a single line of JavaScript.

Note that p:hotkey component behavior is global. Non-input components like p:tree cannot have owned key listeners since they can't be "focused" (or at least by default behavior), just like you pointed.

However, here it is:

<h:form>
    <p:hotkey bind="left" actionListener="#{testBean.onLeft}" process="@form" update="target" />
    <p:hotkey bind="right" actionListener="#{testBean.onRight}" process="@form" update="target" />
    <p:hotkey bind="up" actionListener="#{testBean.onUp}" process="@form" update="target" />
    <p:hotkey bind="down" actionListener="#{testBean.onDown}" process="@form" update="target" />
    <p:hotkey bind="ctrl+a" actionListener="#{testBean.onAdd}" process="@form" update="target" />
    <p:hotkey bind="ctrl+d" actionListener="#{testBean.onDelete}" process="@form" update="target" />

    <h:panelGroup id="target">

        <p:tree value="#{testBean.root}" var="data" selectionMode="single"
            selection="#{testBean.selection}" dynamic="true">
            <p:treeNode expandedIcon="ui-icon-folder-open" collapsedIcon="ui-icon-folder-collapsed">
                <h:outputText value="#{data}" />
            </p:treeNode>
        </p:tree>

        <br />

        <h3>current selection: #{testBean.selection.data}</h3>

    </h:panelGroup>
</h:form>

and this is the managed bean:

@ManagedBean
@ViewScoped
public class TestBean implements Serializable
{
    private static final long serialVersionUID = 1L;

    private DefaultTreeNode root;

    private TreeNode selection;

    @PostConstruct
    public void init()
    {
        root = new DefaultTreeNode("node");
        root.setSelectable(false);

        DefaultTreeNode node_0 = new DefaultTreeNode("node_0");
        DefaultTreeNode node_1 = new DefaultTreeNode("node_1");
        DefaultTreeNode node_0_0 = new DefaultTreeNode("node_0_0");
        DefaultTreeNode node_0_1 = new DefaultTreeNode("node_0_1");
        DefaultTreeNode node_1_0 = new DefaultTreeNode("node_1_0");
        DefaultTreeNode node_1_1 = new DefaultTreeNode("node_1_1");

        node_0.setParent(root);
        root.getChildren().add(node_0);

        node_1.setParent(root);
        root.getChildren().add(node_1);

        node_0_0.setParent(node_0);
        node_0.getChildren().add(node_0_0);

        node_0_1.setParent(node_0);
        node_0.getChildren().add(node_0_1);

        node_1_0.setParent(node_1);
        node_1.getChildren().add(node_1_0);

        node_1_1.setParent(node_1);
        node_1.getChildren().add(node_1_1);

        selection = node_0;
        node_0.setSelected(true);
    }

    private void initSelection()
    {
        List<TreeNode> children = root.getChildren();
        if(!children.isEmpty())
        {
            selection = children.get(0);
            selection.setSelected(true);
        }
    }

    public void onLeft()
    {
        if(selection == null)
        {
            initSelection();
            return;
        }

        if(selection.isExpanded())
        {
            selection.setExpanded(false);
            return;
        }

        TreeNode parent = selection.getParent();
        if(parent != null && !parent.equals(root))
        {
            selection.setSelected(false);
            selection = parent;
            selection.setSelected(true);
        }
    }

    public void onRight()
    {
        if(selection == null)
        {
            initSelection();
            return;
        }

        if(selection.isLeaf())
        {
            return;
        }

        if(!selection.isExpanded())
        {
            selection.setExpanded(true);
            return;
        }

        List<TreeNode> children = selection.getChildren();
        if(!children.isEmpty())
        {
            selection.setSelected(false);
            selection = children.get(0);
            selection.setSelected(true);
        }
    }

    public void onUp()
    {
        if(selection == null)
        {
            initSelection();
            return;
        }

        TreeNode prev = findPrev(selection);
        if(prev != null)
        {
            selection.setSelected(false);
            selection = prev;
            selection.setSelected(true);
        }
    }

    public void onDown()
    {
        if(selection == null)
        {
            initSelection();
            return;
        }

        if(selection.isExpanded())
        {
            List<TreeNode> children = selection.getChildren();
            if(!children.isEmpty())
            {
                selection.setSelected(false);
                selection = children.get(0);
                selection.setSelected(true);
                return;
            }
        }

        TreeNode next = findNext(selection);
        if(next != null)
        {
            selection.setSelected(false);
            selection = next;
            selection.setSelected(true);
        }
    }

    public void onAdd()
    {
        if(selection == null)
        {
            selection = root;
        }

        TreeNode node = createNode();
        node.setParent(selection);
        selection.getChildren().add(node);
        selection.setExpanded(true);

        selection.setSelected(false);
        selection = node;
        selection.setSelected(true);
    }

    public void onDelete()
    {
        if(selection == null)
        {
            return;
        }

        TreeNode parent = selection.getParent();
        parent.getChildren().remove(selection);

        if(!parent.equals(root))
        {
            selection = parent;
            selection.setSelected(true);

            if(selection.isLeaf())
            {
                selection.setExpanded(false);
            }
        }
        else
        {
            selection = null;
        }

    }

    // create the new node the way you like, this is an example
    private TreeNode createNode()
    {
        int prog = 0;
        TreeNode lastNode = Iterables.getLast(selection.getChildren(), null);
        if(lastNode != null)
        {
            prog = NumberUtils.toInt(StringUtils.substringAfterLast(String.valueOf(lastNode.getData()), "_"), -1) + 1;
        }

        return new DefaultTreeNode(selection.getData() + "_" + prog);
    }

    private TreeNode findNext(TreeNode node)
    {
        TreeNode parent = node.getParent();
        if(parent == null)
        {
            return null;
        }

        List<TreeNode> brothers = parent.getChildren();
        int index = brothers.indexOf(node);
        if(index < brothers.size() - 1)
        {
            return brothers.get(index + 1);
        }

        return findNext(parent);
    }

    private TreeNode findPrev(TreeNode node)
    {
        TreeNode parent = node.getParent();
        if(parent == null)
        {
            return null;
        }

        List<TreeNode> brothers = parent.getChildren();
        int index = brothers.indexOf(node);
        if(index > 0)
        {
            return findLastUnexpanded(brothers.get(index - 1));
        }

        if(!parent.equals(root))
        {
            return parent;
        }

        return null;

    }

    private TreeNode findLastUnexpanded(TreeNode node)
    {
        if(!node.isExpanded())
        {
            return node;
        }

        List<TreeNode> children = node.getChildren();
        if(children.isEmpty())
        {
            return node;
        }

        return findLastUnexpanded(Iterables.getLast(children));
    }

    public TreeNode getRoot()
    {
        return root;
    }

    public TreeNode getSelection()
    {
        return selection;
    }

    public void setSelection(TreeNode selection)
    {
        this.selection = selection;
    }
}

UPDATE

Maybe I found an interesting solution to attach key bindings to single DOM elements:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:ui="http://xmlns.jcp.org/jsf/facelets"
    xmlns:h="http://xmlns.jcp.org/jsf/html" xmlns:f="http://xmlns.jcp.org/jsf/core"
    xmlns:cc="http://xmlns.jcp.org/jsf/composite" xmlns:c="http://xmlns.jcp.org/jsp/jstl/core"
    xmlns:fn="http://xmlns.jcp.org/jsp/jstl/functions" xmlns:p="http://primefaces.org/ui"
    xmlns:o="http://omnifaces.org/ui" xmlns:of="http://omnifaces.org/functions"
    xmlns:s="http://shapeitalia.com/jsf2" xmlns:sc="http://xmlns.jcp.org/jsf/composite/shape"
    xmlns:e="http://java.sun.com/jsf/composite/cc" xmlns:pt="http://xmlns.jcp.org/jsf/passthrough">

<h:head>
    <title>test hotkey</title>
</h:head>

<h:body>
    <h:form>
        <h:panelGroup id="container1">
            <s:hotkey bind="left" actionListener="#{testBean.onLeft}" update="container1" />
            <s:hotkey bind="right" actionListener="#{testBean.onRight}" update="container1" />
            <s:hotkey bind="up" actionListener="#{testBean.onUp}" update="container1" />
            <s:hotkey bind="down" actionListener="#{testBean.onDown}" update="container1" />
            <s:hotkey bind="ctrl+a" actionListener="#{testBean.onAdd}" update="container1" />
            <s:hotkey bind="ctrl+d" actionListener="#{testBean.onDelete}" update="container1" />

            <p:tree value="#{testBean.root}" var="data" selectionMode="single"
                selection="#{testBean.selection}" dynamic="true" pt:tabindex="1">
                <p:treeNode expandedIcon="ui-icon-folder-open"
                    collapsedIcon="ui-icon-folder-collapsed">
                    <h:outputText value="#{data}" />
                </p:treeNode>
            </p:tree>
            <br />

            <h3>current selection: #{testBean.selection.data}</h3>
        </h:panelGroup>
    </h:form>
</h:body>
</html>

three important things:

  1. h:panelGroup attribute id is required, otherwise it is not rendered as DOM element. style, styleClass, and other render-enable attributes can be used with or instead.
  2. Note that pt:tabindex=1 on p:tree: it is required to enable "focus". pt is the namespace used for "passthrough" attributes and only works in JSF 2.2.
  3. I had to customize HotkeyRenderer in order to attach the DOM event listener to a specific DOM element instead of the entire document: now it's s:hotkey instead of p:hotkey. My implementation attachs it to DOM element associated to parent component, continue read for implementation.

the modified renderer:

@FacesRenderer(componentFamily = Hotkey.COMPONENT_FAMILY, rendererType = "it.shape.HotkeyRenderer")
public class HotkeyRenderer extends org.primefaces.component.hotkey.HotkeyRenderer
{
    @SuppressWarnings("resource")
    @Override
    public void encodeEnd(FacesContext context, UIComponent component) throws IOException
    {
        ResponseWriter writer = context.getResponseWriter();
        Hotkey hotkey = (Hotkey) component;
        String clientId = hotkey.getClientId(context);

        String targetClientId = hotkey.getParent().getClientId();

        writer.startElement("script", null);
        writer.writeAttribute("type", "text/javascript", null);

        writer.write("$(function() {");
        writer.write("$(PrimeFaces.escapeClientId('" + targetClientId + "')).bind('keydown', '" + hotkey.getBind() + "', function(){");

        if(hotkey.isAjaxified())
        {
            UIComponent form = ComponentUtils.findParentForm(context, hotkey);

            if(form == null)
            {
                throw new FacesException("Hotkey '" + clientId + "' needs to be enclosed in a form when ajax mode is enabled");
            }

            AjaxRequestBuilder builder = RequestContext.getCurrentInstance().getAjaxRequestBuilder();

            String request = builder.init()
                .source(clientId)
                .form(form.getClientId(context))
                .process(component, hotkey.getProcess())
                .update(component, hotkey.getUpdate())
                .async(hotkey.isAsync())
                .global(hotkey.isGlobal())
                .delay(hotkey.getDelay())
                .timeout(hotkey.getTimeout())
                .partialSubmit(hotkey.isPartialSubmit(), hotkey.isPartialSubmitSet())
                .resetValues(hotkey.isResetValues(), hotkey.isResetValuesSet())
                .ignoreAutoUpdate(hotkey.isIgnoreAutoUpdate())
                .onstart(hotkey.getOnstart())
                .onerror(hotkey.getOnerror())
                .onsuccess(hotkey.getOnsuccess())
                .oncomplete(hotkey.getOncomplete())
                .params(hotkey)
                .build();

            writer.write(request);

        }
        else
        {
            writer.write(hotkey.getHandler());
        }

        writer.write(";return false;});});");

        writer.endElement("script");
    }
}

and finally this is the taglib definition for the new s:hotkey (it's a copy/paste of the original with the only difference of <renderer-type>it.shape.HotkeyRenderer</renderer-type>):

<?xml version="1.0" encoding="UTF-8"?>
<facelet-taglib version="2.2" xmlns="http://xmlns.jcp.org/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-facelettaglibrary_2_2.xsd">
    <namespace>http://shapeitalia.com/jsf2</namespace>

    <tag>
        <description><![CDATA[HotKey is a generic key binding component that can bind any formation of keys to javascript event handlers or ajax calls.]]></description>
        <tag-name>hotkey</tag-name>
        <component>
            <component-type>org.primefaces.component.Hotkey</component-type>
            <renderer-type>it.shape.HotkeyRenderer</renderer-type>
        </component>
        <attribute>
            <description><![CDATA[Unique identifier of the component in a namingContainer.]]></description>
            <name>id</name>
            <required>false</required>
            <type>java.lang.String</type>
        </attribute>
        <attribute>
            <description><![CDATA[Boolean value to specify the rendering of the component, when set to false component will not be rendered.]]></description>
            <name>rendered</name>
            <required>false</required>
            <type>java.lang.Boolean</type>
        </attribute>
        <attribute>
            <description><![CDATA[An el expression referring to a server side UIComponent instance in a backing bean.]]></description>
            <name>binding</name>
            <required>false</required>
            <type>javax.faces.component.UIComponent</type>
        </attribute>
        <attribute>
            <description><![CDATA[An actionlistener that'd be processed in the partial request caused by uiajax.]]></description>
            <name>actionListener</name>
            <required>false</required>
            <type>javax.faces.event.ActionListener</type>
        </attribute>
        <attribute>
            <description><![CDATA[A method expression that'd be processed in the partial request caused by uiajax.]]></description>
            <name>action</name>
            <required>false</required>
            <type>javax.el.MethodExpression</type>
        </attribute>
        <attribute>
            <description><![CDATA[Boolean value that determines the phaseId, when true actions are processed at apply_request_values, when false at invoke_application phase.]]></description>
            <name>immediate</name>
            <required>false</required>
            <type>java.lang.Boolean</type>
        </attribute>
        <attribute>
            <description><![CDATA[The Key binding. Required.]]></description>
            <name>bind</name>
            <required>true</required>
            <type>java.lang.String</type>
        </attribute>
        <attribute>
            <description><![CDATA[Client side id of the component(s) to be updated after async partial submit request.]]></description>
            <name>update</name>
            <required>false</required>
            <type>java.lang.String</type>
        </attribute>
        <attribute>
            <description><![CDATA[Component id(s) to process partially instead of whole view.]]></description>
            <name>process</name>
            <required>false</required>
            <type>java.lang.String</type>
        </attribute>
        <attribute>
            <description><![CDATA[Javascript event handler to be executed when the key binding is pressed.]]></description>
            <name>handler</name>
            <required>false</required>
            <type>java.lang.String</type>
        </attribute>
        <attribute>
            <description><![CDATA[Javascript handler to execute before ajax request is begins.]]></description>
            <name>onstart</name>
            <required>false</required>
            <type>java.lang.String</type>
        </attribute>
        <attribute>
            <description><![CDATA[Javascript handler to execute when ajax request is completed.]]></description>
            <name>oncomplete</name>
            <required>false</required>
            <type>java.lang.String</type>
        </attribute>
        <attribute>
            <description><![CDATA[Javascript handler to execute when ajax request fails.]]></description>
            <name>onerror</name>
            <required>false</required>
            <type>java.lang.String</type>
        </attribute>
        <attribute>
            <description><![CDATA[Javascript handler to execute when ajax request succeeds.]]></description>
            <name>onsuccess</name>
            <required>false</required>
            <type>java.lang.String</type>
        </attribute>
        <attribute>
            <description><![CDATA[Global ajax requests are listened by ajaxStatus component, setting global to false will not trigger ajaxStatus. Default is true.]]></description>
            <name>global</name>
            <required>false</required>
            <type>java.lang.Boolean</type>
        </attribute>
        <attribute>
            <description><![CDATA[If less than delay milliseconds elapses between calls to request() only the most recent one is sent and all other requests are discarded. The default value of this option is null. If the value of delay is the literal string 'none' without the quotes or the default, no delay is used.]]></description>
            <name>delay</name>
            <required>false</required>
            <type>java.lang.String</type>
        </attribute>
        <attribute>
            <description><![CDATA[Defines the timeout for the ajax request.]]></description>
            <name>timeout</name>
            <required>false</required>
            <type>java.lang.Integer</type>
        </attribute>
        <attribute>
            <description><![CDATA[When set to true, ajax requests are not queued. Default is false.]]></description>
            <name>async</name>
            <required>false</required>
            <type>java.lang.Boolean</type>
        </attribute>
        <attribute>
            <description><![CDATA[When enabled, only values related to partially processed components would be serialized for ajax 
            instead of whole form.]]></description>
            <name>partialSubmit</name>
            <required>false</required>
            <type>java.lang.Boolean</type>
        </attribute>
        <attribute>
            <description><![CDATA[If true, indicate that this particular Ajax transaction is a value reset transaction. This will cause resetValue() to be called on any EditableValueHolder instances encountered as a result of this ajax transaction. If not specified, or the value is false, no such indication is made.]]></description>
            <name>resetValues</name>
            <required>false</required>
            <type>java.lang.Boolean</type>
        </attribute>
        <attribute>
            <description><![CDATA[If true, components which autoUpdate="true" will not be updated for this request. If not specified, or the value is false, no such indication is made.]]></description>
            <name>ignoreAutoUpdate</name>
            <required>false</required>
            <type>java.lang.Boolean</type>
        </attribute>
    </tag>
</facelet-taglib>

whew, it was hard ;)

Michele Mariotti
  • 7,372
  • 5
  • 41
  • 73
  • That's correct so far. But maybe the question was to narrow, that this is working results already from the (cited) answer below. I surely will give you the proposed bounty if this will be the only reply, but i dare to change my question once again. The solution proposed is only working as long as there are no other input elements on the page. I'm looking for the "thats the way we handle accelerators in JSF" kind of answer. – mtraut Jan 10 '15 at 14:25
  • See updated answer. Maybe it's not the definitive way to handle accelerators, but it's a step forward ;) – Michele Mariotti Jan 11 '15 at 14:26
  • I appreciate your enourmous investment in this topic. I already gained a lot with your ready to run tree snippet. With this detailed dissection of the component internals i will surely learn a lot. Hope you will stick to another one of my questions some fine day... B.t.w. what do you mean with "pt:tabindex=1 on p:tree: it is required to enable "focus""??? – mtraut Jan 12 '15 at 23:27
  • You are welcome, it's a very good playground for me too. Here is a little [fiddle](http://jsfiddle.net/r1pa4rpz/4/) to show the tabindex thing: click on indexed div and press a key. – Michele Mariotti Jan 13 '15 at 08:17
1

Until now, there's no really satisfying answer so far. I summarize my findings:

  • Some JSF components have "inner logic" that bind some keys to component specific features. Too bad that "intelligent components" like <p:tree /> don't even bind arrow key navigation.
  • So you try to emulate and find <p:hotkey/>. No you can (as shown in the very extensive answer by @michele-mariotti) feel a little comfortable wih your component.
  • Then you add input features to the tree... And hotkeys are breaking down. You do not know for what reasons (and, really, i think you should not have to...).
  • So you start digging around and suddenly find yourself in JavaScript and DOM wonderland.
  • The "hotkey" library for the ubiquitous jQuery seems to bring help. Or one of the 1000 others you bring up when searching for this stuff. Better take the right one from the start (which one is it?).
  • So you start adding ugly jQuery expressions for each and every accelerator, first on the document, then down on every input component (as shown e.g. here). Your page starts beeing a mess.
  • But you're happy - at least after two days you have brought up a simple tree..
  • Now you add sugar. You add <p:inplace /> or simply add new tree nodes. Your hotkeys break down.
  • Oh, yes, you should have known: The dynamic inputs are not bound to hotkeys. Add some more JavaScript hacks to the page...
  • But hey, what's this: Testing all your hotkey stuff, you forgot to enter values in the tree input fields. Now you realize: it's not working!! Again some searching: Seems to be a well known bug/missing feature for years. Primefaces removes focus immediately after activating the tree input. Well, who on earth makes input in a tree...
  • So, here's where you could debug some some sophisticated Primefaces JavaScript or add some other equally sophisticated JavaScript to force the focus back to this field. You could realize you use the wrong component library and restart with Richfaces tree, Omnifaces tree or whatever. You could resign to use web technology, sleep another 2 years and come back to see if basic technology has evolved to be usable. Is Java web simply a playground for tinkerers?
  • After this piece of rant, is there anybody that can help with some advice?
Community
  • 1
  • 1
mtraut
  • 4,720
  • 3
  • 24
  • 33
0

I found a workaround that does not exactly what was asked, but can handle my scenario.

Adding a "hotkey" component to the form calls the server as requested:

<p:hotkey bind="ctrl+shift+a" update="messages" actionListener="#{demo.doTest}"/>

Similiar component exists in RichFaces, don't know about plain JSF.

What i can't believe is that there's no other way then reverting down to JavaScript (like http://winechess.blogspot.ru/2014/02/datatable-keyboard-navigation.html or http://www.openjs.com/scripts/events/keyboard_shortcuts/) to write usable JSF apps?

And that standard components like tree or table do not have standard keyboard navigation (its 2015, i even do not remember when Web 2.0 was invented).

Any hint to best practice?

mtraut
  • 4,720
  • 3
  • 24
  • 33
0

And some more investigation before a more enlightened brain can lift the secret...

A somewhat similiar q/a solves the problem on how to get to call a backend method from JS if a key was handled in JS - use the

<p:remoteCommand>

see Catch key pressed ajax event without input fields for the ugly details.

Again, this is a global key catch, not component sensitive. But nice to know. Does this exist in plain JSF, too?

Community
  • 1
  • 1
mtraut
  • 4,720
  • 3
  • 24
  • 33