OfficeDev / office-ui-fabric-core

The front-end CSS framework for building experiences for Office and Microsoft 365.
https://developer.microsoft.com/en-us/fabric
Other
3.77k stars 465 forks source link

Dropdown: Cannot modify items after initial load #159

Closed rsmith0906 closed 8 years ago

rsmith0906 commented 9 years ago

After the initial load of the page you cannot modify a Dropdown. After looking at the HTML it appears that Select object is modified but not the corresponding un-ordered list. I plan to write a method that modifies both the select and the un-ordered list but it would be nice to be able to directly modify the list with native JQuery.

ericthompson commented 9 years ago

This is a good idea, but it isn't on our immediate roadmap. We'd love to see what you've got, and would be happy to incorporate it if it makes sense.

oising commented 8 years ago

This is pretty standard functionality, Eric, especially when you think of you using cascading dropdowns where the value of one filters the contents of the next. It's extremely common to use an MVVM framework in the back to bind to the native select too, and have the options change based on some external feature.

JQuery UI components handle this by allowing a manual "refresh" command, so in Office Fabric UI's case, it might look like calling $(".ms-dropdown").Dropdown("refresh") to force a redraw. Now, ideally fabric should be watching the select itself for changes.

Failing all that, the minimum one would expect is a "destroy" option where we can remove the Dropdown effect from the select and then reapply it. Calling $.fn.Dropdown twice currently corrupts the the rendering; it's not even idempotent, another minimal expectation I'd say. Yes, it's early days, so I guess I'll log some individual issues for you to track. I'm only writing this because I'm surprised to see you saying "if it makes sense." One might assume from that, that you guys have never built dynamic html UX before ;)

ericthompson commented 8 years ago

Hi @oising - I think the crux of my response about it "making sense" is geared towards the scope of the Office UI Fabric project. It looks like the core of our misunderstanding centers around this, and I should have been clearer :-)

When it comes to the scope of Office UI Fabric at this stage, we're taking a primary focus on the styling. We explain this in our documentation and in our responses to other issues. We absolutely don't want to rule out our growth into more robust JS, but, for now, our team focuses on the skin. There's plenty of work when it comes to the styling, and this is where we can add the most value as a group of design developers seated in a MS design studio.

Many of the products we support also don't use jQuery and don't want to take a dependency on it. For this reason, we've abstracted Fabric away and only built up the "presentational" JS necessary to show the intended behavior. Sometimes, we use jQuery just to show intended behavior given how powerful and ubiquitous it is even though many of our consumers decide to remove/replace it with their own tech. Building out additional functionality beyond what the designs call for would take time away from our other obligations. We absolutely encourage the community to help us expand the toolkit beyond what we can just support - this is the reasoning behind why we didn't close this issue.

If you've got ideas on how to expand the toolkit, please feel free to open a PR or file some issues so we can keep tabs on it (saw your other issues!). We think it's great to see folks helping out, and will look over any and all PRs - especially those that help make the toolkit even more powerful!

mikewheaton commented 8 years ago

Leaving this open for now, as it seems like a good function to have in our sample JavaScript, and something that may be easier to add as we change those samples to TypeScript.

elegault commented 8 years ago

+1 on wanting this ability. I was just pulling my hair out wondering why the value I was setting it in code wasn't working. I come here to check and sure enough, it's not supported out of the box! FYI when selectedIndex or value is set, or even setting selected='selected' on an individual option - the input element correctly contains the value in all related attributes/properties. It just doesn't update the displayed text and selection.

oising commented 8 years ago

@elegault I modified the included jquery for dropdown to support a "refresh" command - it's not optimal, but it works. Here it is:

(function ($) {
    $.fn.Dropdown = function (command) {

        if (!!command) {
            switch (command.toLowerCase()) {
                case "refresh":
                    console.log("refreshing");
                    var $dropdownWrapper = $(this),
                        $originalDropdown = $dropdownWrapper.children('.ms-Dropdown-select'),
                        $originalDropdownOptions = $originalDropdown.children('option'),
                        newDropdownTitle = "",
                        newDropdownItems = "";

                    $originalDropdownOptions.each(function(index, option) {

                        /** If the option is selected, it should be the new dropdown's title. */
                        if (option.selected) {
                            newDropdownTitle = option.text;
                        }

                        /** Add this option to the list of items. */
                        newDropdownItems += '<li class="ms-Dropdown-item' + ((option.disabled) ? ' is-disabled"' : '"') + '>' + option.text + '</li>';

                    });

                    // update title
                    $dropdownWrapper.find(".ms-Dropdown-title").html(newDropdownTitle);

                    // replace options content
                    var anchor = $dropdownWrapper.find(".ms-Dropdown-items");
                    anchor.children(".ms-Dropdown-item").remove();
                    anchor.append(newDropdownItems);
                    break;
                default:
                    break;
            }

            return this;
        }

        /** Go through each dropdown we've been given. */
        return this.each(function () {

            var $dropdownWrapper = $(this),
                $originalDropdown = $dropdownWrapper.children('.ms-Dropdown-select'),
                $originalDropdownOptions = $originalDropdown.children('option'),
                originalDropdownID = this.id,
                newDropdownTitle = '',
                newDropdownItems = '',
                newDropdownSource = '';

            /** Go through the options to fill up newDropdownTitle and newDropdownItems. */
            $originalDropdownOptions.each(function (index, option) {

                /** If the option is selected, it should be the new dropdown's title. */
                if (option.selected) {
                    newDropdownTitle = option.text;
                }

                /** Add this option to the list of items. */
                newDropdownItems += '<li class="ms-Dropdown-item' + ( (option.disabled) ? ' is-disabled"' : '"' ) + '>' + option.text + '</li>';

            });

            /** Insert the replacement dropdown. */
            newDropdownSource = '<span class="ms-Dropdown-title">' + newDropdownTitle + '</span><ul class="ms-Dropdown-items">' + newDropdownItems + '</ul>';
            $dropdownWrapper.append(newDropdownSource);

            function _openDropdown(evt) {
                if (!$dropdownWrapper.hasClass('is-disabled')) {

                    /** First, let's close any open dropdowns on this page. */
                    $dropdownWrapper.find('.is-open').removeClass('is-open');

                    /** Stop the click event from propagating, which would just close the dropdown immediately. */
                    evt.stopPropagation();

                    /** Before opening, size the items list to match the dropdown. */
                    var dropdownWidth = $(this).parents(".ms-Dropdown").width();
                    $(this).next(".ms-Dropdown-items").css('width', dropdownWidth + 'px');

                    /** Go ahead and open that dropdown. */
                    $dropdownWrapper.toggleClass('is-open');

                    /** Temporarily bind an event to the document that will close this dropdown when clicking anywhere. */
                    $(document).bind("click.dropdown", function(event) {
                        $dropdownWrapper.removeClass('is-open');
                        $(document).unbind('click.dropdown');
                    });
                }
            };

            /** Toggle open/closed state of the dropdown when clicking its title. */
            $dropdownWrapper.on('click', '.ms-Dropdown-title', function(event) {
                _openDropdown(event);
            });

            /** Keyboard accessibility */
            $dropdownWrapper.on('keyup', function(event) {
                var keyCode = event.keyCode || event.which;
                // Open dropdown on enter or arrow up or arrow down and focus on first option
                if (!$(this).hasClass('is-open')) {
                    if (keyCode === 13 || keyCode === 38 || keyCode === 40) {
                       _openDropdown(event);
                       if (!$(this).find('.ms-Dropdown-item').hasClass('is-selected')) {
                        $(this).find('.ms-Dropdown-item:first').addClass('is-selected');
                       }
                    }
                }
                else if ($(this).hasClass('is-open')) {
                    // Up arrow focuses previous option
                    if (keyCode === 38) {
                        if ($(this).find('.ms-Dropdown-item.is-selected').prev().siblings().size() > 0) {
                            $(this).find('.ms-Dropdown-item.is-selected').removeClass('is-selected').prev().addClass('is-selected');
                        }
                    }
                    // Down arrow focuses next option
                    if (keyCode === 40) {
                        if ($(this).find('.ms-Dropdown-item.is-selected').next().siblings().size() > 0) {
                            $(this).find('.ms-Dropdown-item.is-selected').removeClass('is-selected').next().addClass('is-selected');
                        }
                    }
                    // Enter to select item
                    if (keyCode === 13) {
                        if (!$dropdownWrapper.hasClass('is-disabled')) {

                            // Item text
                            var selectedItemText = $(this).find('.ms-Dropdown-item.is-selected').text()

                            $(this).find('.ms-Dropdown-title').html(selectedItemText);

                            /** Update the original dropdown. */
                            $originalDropdown.find("option").each(function(key, value) {
                                if (value.text === selectedItemText) {
                                    $(this).prop('selected', true);
                                } else {
                                    $(this).prop('selected', false);
                                }
                            });
                            $originalDropdown.change();

                            $(this).removeClass('is-open');
                        }
                    }                
                }

                // Close dropdown on esc
                if (keyCode === 27) {
                    $(this).removeClass('is-open');
                }
            });

            /** Select an option from the dropdown. */
            $dropdownWrapper.on('click', '.ms-Dropdown-item', function () {
                if (!$dropdownWrapper.hasClass('is-disabled')) {

                    /** Deselect all items and select this one. */
                    $(this).siblings('.ms-Dropdown-item').removeClass('is-selected')
                    $(this).addClass('is-selected');

                    /** Update the replacement dropdown's title. */
                    $(this).parents().siblings('.ms-Dropdown-title').html($(this).text());

                    /** Update the original dropdown. */
                    var selectedItemText = $(this).text();
                    $originalDropdown.find("option").each(function(key, value) {
                        if (value.text === selectedItemText) {
                            $(this).prop('selected', true);
                        } else {
                            $(this).prop('selected', false);
                        }
                    });
                    $originalDropdown.change();
                }
            });

        });
    };
})(jQuery);
oising commented 8 years ago

I was using knockout to databind the original element, and then I subscribed to the observable so I could invoke a refresh when it changed -- something like this:

ko.applyBindings(viewModel);

console.log("Applying Office Fabric UI behaviour(s).");
$(".ms-Dropdown").Dropdown();

viewModel.filteredTemplates.subscribe(function () {
    console.log("detected change in filtered templates; refreshing.");
    $(".ms-Dropdown:has(select#ddlTemplate)").Dropdown("refresh");
});
// ...
elegault commented 8 years ago

Awesome, thanks Oisin!

MIchaelMainer commented 8 years ago

I tried what @oising provided. It worked for me.

mikewheaton commented 8 years ago

@oising, could you submit a pull request with your changes? Thanks!

andikrueger commented 8 years ago

@oising: Your solution works like a charm. Do you submit a PR or should someone take over?

johnnyongesa commented 8 years ago

@oising your solution is worked for for me, thanks.

johnnyongesa commented 8 years ago

@oising you code works correctly with only one drop down in the form. If there is more than one drop down the form it copies options from one drop down to another. ``` For example If dropdown A has 4 option and dropdown B has 6 options. After a refresh dropdown A has 10 option and B 10 options

mikewheaton commented 8 years ago

We've had some lengthy discussions lately about Fabric's JavaScript. We want to provide useful functionality, while being careful not to slip into making it a full-fledged framework like Angular or React, as we're not resourced to support that level of functionality. Projects like ngOfficeUIFabric are the place for that. 😄

The direction we're heading for the time being is to draw a line between 'helper' functionality and 'data-driven' functionality, and this request is a good example of something that enters into the data-driven arena. Basically the data for the application is expected to be in HTML, and the Fabric JS handles the state of the component.

If you provide Fabric with a select element and call .Dropdown() on that element, we'll generate a component with basic functionality to match the native control. But when you need to add/remove/update the options in the component, we ask that you update the underlying select and again call .Dropdown() to re-instantiate it. We're certainly open to pull requests that add public functions like addOption(), but it's not something we'll be focusing on at this time.

johnnyongesa commented 8 years ago

Updating the underlying select then calling .Dropdown()breaks the element