Giter Site home page Giter Site logo

cferdinandi / houdini Goto Github PK

View Code? Open in Web Editor NEW
159.0 5.0 17.0 1.37 MB

A simple, accessible show-and-hide/accordion script.

License: MIT License

JavaScript 86.17% CSS 1.83% HTML 11.39% HCL 0.61%
javascript vanilla-js javascript-plugin accordion collapse no-dependencies a11y accessibility disclosure

houdini's Introduction

Houdini Build Status

A simple, accessible show-and-hide/accordion script.

Houdini progressively enhances your markup when it loads. You provide the content, and Houdini layers in the toggle buttons, ARIA attributes, and interactivity for you.

View the Demo on CodePen โ†’

Getting Started | Accordions | Demos | API | What's New? | Browser Compatibility | License


Want to learn how to write your own vanilla JS plugins? Check out my Vanilla JS Pocket Guides or join the Vanilla JS Academy and level-up as a web developer. ๐Ÿš€


Getting Started

Compiled and production-ready code can be found in the dist directory. The src directory contains development code.

1. Include Houdini on your site.

Houdini has two required files: JavaScript and CSS.

There are two versions of the Houdini JavaScript file: the standalone version, and one that comes preloaded with polyfills for matches(), closest(), classList, and CustomEvent(), which are only supported in newer browsers.

If you're including your own polyfills or don't want to enable this feature for older browsers, use the standalone version. Otherwise, use the version with polyfills.

Direct Download

You can download the files directly from GitHub.

<link rel="stylesheet" type="text/css" href="/path/to/houdini.min.css">
<script src="path/to/houdini.polyfills.min.js"></script>

CDN

You can also use the jsDelivr CDN. I recommend linking to a specific version number or version range to prevent major updates from breaking your site. Houdini uses semantic versioning.

<!-- Always get the latest version -->
<!-- Not recommended for production sites! -->
<link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/gh/cferdinandi/houdini/dist/css/houdini.min.js">
<script src="https://cdn.jsdelivr.net/gh/cferdinandi/houdini/dist/js/houdini.polyfills.min.js"></script>

<!-- Get minor updates and patch fixes within a major version -->
<link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/gh/cferdinandi/houdini@10/dist/css/houdini.min.js">
<script src="https://cdn.jsdelivr.net/gh/cferdinandi/houdini@11/dist/js/houdini.polyfills.min.js"></script>

<!-- Get patch fixes within a minor version -->
<link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/gh/cferdinandi/[email protected]/dist/css/houdini.min.js">
<script src="https://cdn.jsdelivr.net/gh/cferdinandi/[email protected]/dist/js/houdini.polyfills.min.js"></script>

<!-- Get a specific version -->
<link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/gh/cferdinandi/[email protected]/dist/css/houdini.min.js">
<script src="https://cdn.jsdelivr.net/gh/cferdinandi/[email protected]/dist/js/houdini.polyfills.min.js"></script>

NPM

You can also use NPM (or your favorite package manager).

npm install houdinijs

2. Add the markup to your HTML.

  1. Wrap your content in a div element and assign it a unique ID.
  2. Add a selector (the example below uses the [data-houdini] attribute, but this could be a class or ID instead).
  3. Add a [data-houdini-button] attribute with the text you want for your button. Houdini will create the button and add any required ARIA attributes automatically when it loads.
<div data-houdini data-houdini-button="Show More" id="show-me">
	<p>Now you see me, now you don't.</p>
</div>

Note: The ID can serve as the selector. However, if you'll be including multiple disclosures with the same options and settings, it's better to use a shared selector like a class or data attribute for all of them.

3. Initialize Houdini.

In the footer of your page, after the content, initialize Houdini by passing in the selector for your disclosure component(s). And that's it, you're done. Nice work!

<script>
	var disclosure = new Houdini('[data-houdini]');
</script>

Here's a simple demo you can play with.

Accordions

Houdini also supports accordion groups.

Accordion Markup

For semantic reasons, these should have a heading/content relationship. You can use heading elements, data lists, and more.

The heading should have a [data-houdini-toggle] attribute, with a value equal to the ID of the content it toggles.

<h2 data-houdini-toggle="yo-ho-ho">Yo, ho ho!</h2>
<div data-houdini-group id="yo-ho-ho">Yo, ho ho and a bottle of rum!</div>

<h2 data-houdini-toggle="ahoy">Ahoy, there!</h2>
<div data-houdini-group id="ahoy">Ahoy there, matey!</div>

Accordion Initialization

Initialize an accordion by passing in the isAccordion option with a value of true.

var accordion = new Houdini('[data-houdini-group]', {
	isAccordion: true
});

If opening one accordion section should close any others in the group that are open, also include the collapseOthers option, with a value of true.

It's recommended that you give each group a unique selector if using this option.

var accordion = new Houdini('[data-houdini-group="pirates"]', {
	isAccordion: true,
	collapseOthers: true
});

Demos

API

Houdini includes smart defaults and works right out of the box. But if you want to customize things, it also has a robust API that provides multiple ways for you to adjust the default options and settings.

Expanded by Default

If you want specific disclosures or accordions to be expanded by default, add the .is-expanded class to your markup.

Expanded Disclosure

<div data-houdini class="is-expanded" id="show-me">
	<p>Now you see me, now you don't.</p>
</div>

Expanded Accordion

<h2 data-houdini-toggle="yo-ho-ho">Yo, ho ho!</h2>
<div data-houdini-group class="is-expanded" id="yo-ho-ho">Yo, ho ho and a bottle of rum!</div>

<h2 data-houdini-toggle="ahoy">Ahoy, there!</h2>
<div data-houdini-group id="ahoy">Ahoy there, matey!</div>

To expand all items by default, pass in the expanded option with a value of true.

// Disclosure expanded by default
var disclosure = new Houdini('[data-houdini]', {
	expanded: true
});

// Accordions expanded by default
var accordion = new Houdini('[data-houdini-group]', {
	isAccordion: true,
	expanded: true
});

Extra Info for Screen Readers

If you want to add extra information to your button for screen reader users, include the [data-houdini-label] attribute. This will add an aria-label to the button.

<div data-houdini data-houdini-button="Show More" data-houdini-label="Show more about pirates" id="show-me">
	<p>Now you see me, now you don't.</p>
</div>

Using Your Own Buttons

If you want to have more control over the toggle buttons, you can include your own instead.

Make sure your button has a [data-houdini-toggle] attribute with a value that matches the ID of the content it's supposed to toggle. You should also add the [hidden] attribute to hide the button until the script loads (Houdini will make it visible automatically).

<button data-houdini-toggle="show-me-too" aria-label="Show more about pirates, too" hidden>
	Show me, too
</button>

Note: You DO NOT need to include the [data-houdini-button] or [data-houdini-label] attributes, since you're creating your own button and can add that content directly.

Options and Settings

You can override the default settings by passing in user options as a second argument when instantiating.

var disclosure = new Houdini('[data-houdini]', {

	// Content
	contentClass: 'houdini',
	expanded: false,
	expandedClass: 'is-expanded',

	// Toggle Buttons
	btnAfter: false, // If true, load toggle button after the content
	btnClass: 'houdini-toggle', // The class to add to toggle buttons
	btnAttribute: 'data-houdini-toggle', // The data attribute to use for toggle buttons
	btnTextAttribute: 'data-houdini-button', // The data attribute for the button text
	btnLabelAttribute: 'data-houdini-label', // The data attribute for aria-label text
	btnPreexisting: 'data-houdini-button-preexisting', // The data attribute added to pre-existing buttons

	// Accordion
	isAccordion: false, // If true, treat as an accordion
	collapseOthers: false, // If true, only allow on open piece of content at a time
	headingClass: 'houdini-heading', // The class to add to the heading element

	// Icons
	icon: -1, // If true, include an expand/collapse icon
	iconClass: 'houdini-toggle-icon', // The class to use for the expand/collapse icon
	iconAttribute: 'data-houdini-icon', // The data attribute to use for the expand/collapse icon
	iconShow: '+', // The icon to expand an accordion
	iconHide: '&ndash;', // The icon to collapse an accordion

	// Custom Events
	emitEvents: true // If true, emit custom events

});

Custom Events

Houdini emits five custom events:

  • houdiniExpand is emitted on a content element after it's expanded.
  • houdiniCollapse is emitted on a content element after it's collapsed.
  • houdiniInitialize is emitted on the document when the script is initialized, but before the DOM is setup.
  • houdiniSetup is emitted on the document after the DOM is setup.
  • houdiniDestroy is emitted on the document after an initialization is destroyed.

On the houdiniExpand and houdiniCollapse event, the event.detail object includes the content and button. For the houdiniInitialize, houdiniSetup, and houdiniDestroy event, it includes the settings object.

All five events bubble, and can be captured with event delegation.

// Log scroll events
var logHoudiniEvent = function (event) {

	// The event type
	console.log('type:', event.type);

	// The content being expanded or collapsed
	console.log('content:', event.detail.content);

	// The button for the content
	console.log('button:', event.detail.button);

};

// Listen for scroll events
document.addEventListener('houdiniExpand', logHoudiniEvent, false);
document.addEventListener('houdiniCollapse', logHoudiniEvent, false);

Methods

You can also call Houdini's methods in your own scripts.

toggle()

Toggle the visibility of a content area. Accepts an element or selector string as an argument. Can be the toggle or content.

var disclosure = new Houdini();

// Selector string
disclosure.toggle('#yo-ho-ho');

// Content element
var content = document.querySelector('#yo-ho-ho');
disclosure.toggle(content);

// Button element
var btn = document.querySelector('[data-houdini-toggle="yo-ho-ho"]');
disclosure.toggle(btn);

expand()

Expand a content area. Accepts an element or selector string as an argument. Can be the toggle or content.

var disclosure = new Houdini();

// Selector string
disclosure.expand('#yo-ho-ho');

// Content element
var content = document.querySelector('#yo-ho-ho');
disclosure.expand(content);

// Button element
var btn = document.querySelector('[data-houdini-toggle="yo-ho-ho"]');
disclosure.expand(btn);

collapse()

Collapse a content area. Accepts an element or selector string as an argument. Can be the toggle or content.

var disclosure = new Houdini();

// Selector string
disclosure.collapse('#yo-ho-ho');

// Content element
var content = document.querySelector('#yo-ho-ho');
disclosure.collapse(content);

// Button element
var btn = document.querySelector('[data-houdini-toggle="yo-ho-ho"]');
disclosure.collapse(btn);

setup()

Adds the required markup to the DOM. This runs automatically when you initialize Houdini, but if you add new elements to the DOM later, you should run it again.

var disclosure = new Houdini('[data-houdini]');

// Some time later...
disclosure.setup();

destroy()

Destroy an instantiation of Houdini and restore the markup to its original state.

var disclosure = new Houdini('[data-houdini]');

// Some time later...
disclosure.destroy();

What's New?

  • Supports multiple instantiations at once.
  • Better accessibility and semantics.
  • Automatically progressively enhances your markup for you.
  • Deprecated callbacks in favor of custom events.

Migrating to Houdini 11 from Houdini 10

Based on feedback from accessibility experts, Houdini no longer supports changing button text or using the same text for all buttons.

  • Every content area now needs a [data-houdini-button] attribute or a button element with a [data-houdini-toggle] attribute that matches the content ID.
  • The btnShow and btnHide options no longer exist.

Migrating to Houdini 10 from Older Versions

The entire markup and initialization process has changed in Houdini 10. To migrate:

  • Remove existing toggle buttons from your markup.
  • Switch the .active to .is-expanded for content you want expanded by default.
  • Accordions should use a semantic heading/content relational structure instead of anchor links.
  • Instantiate Houdini with new Houdini() instead of houdini.init().
  • Move any callbacks to custom event listeners.

Kudos ๐Ÿ‘

Major kudos to Scott O'Hara for walking me through the nuances of accordion accessibility and giving me tons of feedback along the way.

Browser Compatibility

Houdini works in all modern browsers, and IE 9 and above.

Houdini is built with modern JavaScript APIs, and uses progressive enhancement. If the JavaScript file fails to load, or if your site is viewed on older and less capable browsers, all of your content will be displayed as-is.

Polyfills

Support back to IE9 requires polyfills for matches(), closest(), classList, and CustomEvent(). Without them, support starts with Edge.

Use the included polyfills version of Houdini, or include your own.

License

The code is available under the MIT License.

houdini's People

Contributors

barryels avatar cferdinandi avatar jerivas avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar

houdini's Issues

collapse only section being expanded

HI,
thanks for the great script.

Is there a possibility to collapse/expand sections independently?
Meaning that expanding one section doesn't automatically collapse other expanded section.

Thanks!

errors on destroying accordion

In your demo if I open console and run disclosure.destroy() I get the following errors:
Chrome: "Cannot read property 'classList' of undefined"
Firefox: "heading is undefined"

Add optimize js

package.json

"gulp-optimize-js": "^1.0.2",

gulpfile.js

var optimizejs = require('gulp-optimize-js');

    var jsTasks = lazypipe()
        .pipe(header, banner.full, { package : package })
        .pipe(optimizejs)
        .pipe(gulp.dest, paths.scripts.output)
        .pipe(rename, { suffix: '.min' })
        .pipe(uglify)
        .pipe(optimizejs)
        .pipe(header, banner.min, { package : package })
        .pipe(gulp.dest, paths.scripts.output);

Remove ability to change button text

Keep button text the same whether or expanded or collapsed. Disorienting to screen reader users.

Can allow for icons to be used to show state (like with accordions)

Adding class to non-active collapse-toggle

Hey there,
I am currently enjoying your simple yet effective script, thanks. I stumbled however upon something I'd like to have implemented, maybe that's a possible enhancement, so here's the deal:

Could we add a class to the collapse-toggle whose content isn't active? I see this is kinda what's to be achieved by having collapse-text-show and -hide, but not quite the same. I'm toggling with images and don't want them to be included twice .. but asking doesn't cost anything, right?

Cheers and keep up the good work,
I love many of your scripts.

S1SYPHOS

Just a question about the click function

hey there :)

I really like all of these modules ๐Ÿ‘

  • I was wondering if there would be any benefits of having the click function for houdini to be a .on click instead? like:
(function($) {
    $(function () {
        $('.collapse-toggle').on('click', function(e)({ // When a link or button with the .collapse-toggle class is clicked
            e.preventDefault(); // Prevent the default action from occurring

            // Set Variables
            var dataID = $(this).attr('data-target'); // dataID is the data-target value
            var hrefID = $(this).attr('href'); // hrefID is the href value

            // Toggle the Active Class
            if (dataID)  { // If the clicked element has a data-target
                $(dataID).toggleClass('active'); // Add or remove the .active class from the element whose ID matches the data-target value
            }
            else { // Otherwise
                $(hrefID).toggleClass('active'); // Add or remove the .active class from the element whose ID matches the href value
            }
        });
    });
})(jQuery);
  • I am just curious if you think there are any performance benefits of adding .on to the function?
  • thoughts?

NPM publication

I install with NPM and I use webpack, but I get error:
houdinijs__WEBPACK_IMPORTED_MODULE_1___default.a is not a constructor

I looked at, the prev version no use Class (constructor). I read your README.md, but not compatible with prev NPM version. The last NPM version: 9.4.2

Please upgrade Houdini in NPM repository.

Better a11y

(function (root, factory) {
    if ( typeof define === 'function' && define.amd ) {
        define([], factory(root));
    } else if ( typeof exports === 'object' ) {
        module.exports = factory(root);
    } else {
        root.houdini = factory(root);
    }
})(typeof global !== 'undefined' ? global : this.window || this.global, function (root) {

    'use strict';

    //
    // Variables
    //

    var houdini = {}; // Object for public APIs
    var supports = 'querySelector' in document && 'addEventListener' in root && 'classList' in document.createElement('_'); // Feature test
    var settings;

    // Default settings
    var defaults = {
        selector: '[data-collapse]',
        toggleActiveClass: 'active',
        contentActiveClass: 'active',
        initClass: 'js-houdini',
        callback: function () {}
    };


    //
    // Methods
    //

    /**
     * A simple forEach() implementation for Arrays, Objects and NodeLists
     * @private
     * @param {Array|Object|NodeList} collection Collection of items to iterate
     * @param {Function} callback Callback function for each iteration
     * @param {Array|Object|NodeList} scope Object/NodeList/Array that forEach is iterating over (aka `this`)
     */
    var forEach = function (collection, callback, scope) {
        if (Object.prototype.toString.call(collection) === '[object Object]') {
            for (var prop in collection) {
                if (Object.prototype.hasOwnProperty.call(collection, prop)) {
                    callback.call(scope, collection[prop], prop, collection);
                }
            }
        } else {
            for (var i = 0, len = collection.length; i < len; i++) {
                callback.call(scope, collection[i], i, collection);
            }
        }
    };

    /**
     * Merge defaults with user options
     * @private
     * @param {Object} defaults Default settings
     * @param {Object} options User options
     * @returns {Object} Merged values of defaults and options
     */
    var extend = function () {

        // Variables
        var extended = {};
        var deep = false;
        var i = 0;
        var length = arguments.length;

        // Check if a deep merge
        if ( Object.prototype.toString.call( arguments[0] ) === '[object Boolean]' ) {
            deep = arguments[0];
            i++;
        }

        // Merge the object into the extended object
        var merge = function (obj) {
            for ( var prop in obj ) {
                if ( Object.prototype.hasOwnProperty.call( obj, prop ) ) {
                    // If deep merge and property is an object, merge properties
                    if ( deep && Object.prototype.toString.call(obj[prop]) === '[object Object]' ) {
                        extended[prop] = extend( true, extended[prop], obj[prop] );
                    } else {
                        extended[prop] = obj[prop];
                    }
                }
            }
        };

        // Loop through each object and conduct a merge
        for ( ; i < length; i++ ) {
            var obj = arguments[i];
            merge(obj);
        }

        return extended;

    };

    /**
     * Get the closest matching element up the DOM tree
     * @param {Element} elem Starting element
     * @param {String} selector Selector to match against (class, ID, or data attribute)
     * @return {Boolean|Element} Returns false if not match found
     */
    var getClosest = function ( elem, selector ) {

        // Variables
        var firstChar = selector.charAt(0);
        var attribute, value;

        // If selector is a data attribute, split attribute from value
        if ( firstChar === '[' ) {
            selector = selector.substr(1, selector.length - 2);
            attribute = selector.split( '=' );

            if ( attribute.length > 1 ) {
                value = true;
                attribute[1] = attribute[1].replace( /"/g, '' ).replace( /'/g, '' );
            }
        }

        // Get closest match
        for ( ; elem && elem !== document; elem = elem.parentNode ) {

            // If selector is a class
            if ( firstChar === '.' ) {
                if ( elem.classList.contains( selector.substr(1) ) ) {
                    return elem;
                }
            }

            // If selector is an ID
            if ( firstChar === '#' ) {
                if ( elem.id === selector.substr(1) ) {
                    return elem;
                }
            }

            // If selector is a data attribute
            if ( firstChar === '[' ) {
                if ( elem.hasAttribute( attribute[0] ) ) {
                    if ( value ) {
                        if ( elem.getAttribute( attribute[0] ) === attribute[1] ) {
                            return elem;
                        }
                    } else {
                        return elem;
                    }
                }
            }

            // If selector is a tag
            if ( elem.tagName.toLowerCase() === selector ) {
                return elem;
            }

        }

        return null;

    };

    /**
     * Stop YouTube, Vimeo, and HTML5 videos from playing when leaving the slide
     * @private
     * @param  {Element} content The content container the video is in
     * @param  {String} activeClass The class asigned to expanded content areas
     */
    var stopVideos = function ( content, activeClass ) {
        if ( !content.classList.contains( activeClass ) ) {
            var iframe = content.querySelector( 'iframe');
            var video = content.querySelector( 'video' );
            if ( iframe ) {
                var iframeSrc = iframe.src;
                iframe.src = iframeSrc;
            }
            if ( video ) {
                video.pause();
            }
        }
    };

    var bringFocus = function ( content, activeClass ) {
        if ( !content.classList.contains( activeClass ) ) return;
        content.focus();
    };

    /**
     * Close all content areas in an expand/collapse group
     * @private
     * @param  {Element} toggle The element that toggled the expand or collapse
     * @param  {Object} settings
     */
    var closeCollapseGroup = function ( toggle, settings ) {
        if ( !toggle.classList.contains( settings.toggleActiveClass ) && toggle.hasAttribute('data-group') ) {

            // Get all toggles in the group
            var groupName = toggle.getAttribute('data-group');
            var group = document.querySelectorAll('[data-group="' + groupName + '"]');

            // Deactivate each toggle and it's content area
            forEach(group, function (item) {
                var content = document.querySelector( item.getAttribute('data-collapse') );
                item.classList.remove( settings.toggleActiveClass );
                content.classList.remove( settings.contentActiveClass );
            });

        }
    };

    /**
     * Toggle the collapse/expand widget
     * @public
     * @param  {Element} toggle The element that toggled the expand or collapse
     * @param  {String} contentID The ID of the content area to expand or collapse
     * @param  {Object} options
     * @param  {Event} event
     */
    houdini.toggleContent = function (toggle, contentID, options) {

        var settings = extend( settings || defaults, options || {} );  // Merge user options with defaults
        var content = document.querySelector(contentID); // Get content area

        // Toggle collapse element
        closeCollapseGroup(toggle, settings); // Close collapse group items
        toggle.classList.toggle( settings.toggleActiveClass );// Change text on collapse toggle
        content.classList.toggle( settings.contentActiveClass ); // Collapse or expand content area
        stopVideos( content, settings.contentActiveClass ); // If content area is closed, stop playing any videos
        bringFocus( content, settings.contentActiveClass ); // If content area is open, bring focus

        settings.callback( toggle, contentID ); // Run callbacks after toggling content

    };

    /**
     * Handle toggle click events
     * @private
     */
    var eventHandler = function (event) {
        var toggle = getClosest(event.target, settings.selector);
        if ( toggle ) {
            if ( toggle.tagName.toLowerCase() === 'a' || toggle.tagName.toLowerCase() === 'button' ) {
                event.preventDefault();
            }
            var contentID = toggle.hasAttribute('data-collapse') ? toggle.getAttribute('data-collapse') : toggle.parentNode.getAttribute('data-collapse');
            houdini.toggleContent( toggle, contentID, settings );
        }
    };

    /**
     * Add a11y attributes to the DOM
     * @param {boolean} remove If true, remove a11y attributes from the DOM
     */
    var addAttributes = function ( remove ) {

        // Get all toggles
        var toggles = document.querySelectorAll( settings.selector );

        // For each toggle
        forEach(toggles, function (toggle) {

            // Get the target content area
            var content = document.querySelector( toggle.getAttribute( 'data-collapse' ) );

            // Remove attributes
            if ( remove ) {
                toggle.removeAttribute( 'aria-hidden' );
                if ( content ) {
                    content.removeAttribute( 'tabindex' );
                }
                return;
            }

            // Add attributes
            toggle.setAttribute( 'aria-hidden', 'true' );
            if ( content ) {
                content.setAttribute( 'tabindex', '-1' );
            }

        });

    };

    /**
     * Destroy the current initialization.
     * @public
     */
    houdini.destroy = function () {
        if ( !settings ) return;
        document.documentElement.classList.remove( settings.initClass );
        addAttributes(true);
        document.removeEventListener('click', eventHandler, false);
        settings = null;
    };

    /**
     * Initialize Houdini
     * @public
     * @param {Object} options User settings
     */
    houdini.init = function ( options ) {

        // feature test
        if ( !supports ) return;

        // Destroy any existing initializations
        houdini.destroy();

        // Merge user options with defaults
        settings = extend( defaults, options || {} );

        // Add class to HTML element to activate conditional CSS
        document.documentElement.classList.add( settings.initClass );

        // Add attributes
        addAttributes();

        // Listen for all click events
        document.addEventListener('click', eventHandler, false);

    };


    //
    // Public APIs
    //

    return houdini;

});
/**
 * @section Houdini
 * Styling for expand-and-collapse widgets
 */

/**
 * Collapse toggle
 */
.collapse-toggle {
    display: none;
    visibility: hidden;

    .js-houdini & {
        cursor: pointer;
        display: inline-block;
        visibility: visible;
    }
}


/**
 * Show/hide text
 */
.active .collapse-text-show,
.collapse-text-hide {
    display: none;
    visibility: hidden;
}

.active .collapse-text-hide {
    display: inline-block;
    visibility: visible;
}


/**
 * The content
 */
.js-houdini .collapse {
    @extend .screen-reader;

    &.active {
        @extend .screen-reader-focusable:active
    }

    &:focus {
        outline: none;
    }
}

a11y considerations

Two options for how to hide the content:

  1. Only hide it visually, but show it visually when a keyboard user tabs into it.
  2. Don't display it at all, and only show it when activated with JavaScript.

I'm leaning towards the first option.

Better a11y attribute adding

Allows for elements added later to still work properly:

(function (root, factory) {
    if ( typeof define === 'function' && define.amd ) {
        define([], factory(root));
    } else if ( typeof exports === 'object' ) {
        module.exports = factory(root);
    } else {
        root.houdini = factory(root);
    }
})(typeof global !== 'undefined' ? global : this.window || this.global, function (root) {

    'use strict';

    //
    // Variables
    //

    var houdini = {}; // Object for public APIs
    var supports = 'querySelector' in document && 'addEventListener' in root && 'classList' in document.createElement('_'); // Feature test
    var settings;

    // Default settings
    var defaults = {
        selector: '[data-collapse]',
        toggleActiveClass: 'active',
        contentActiveClass: 'active',
        initClass: 'js-houdini',
        callback: function () {}
    };


    //
    // Methods
    //

    /**
     * A simple forEach() implementation for Arrays, Objects and NodeLists
     * @private
     * @param {Array|Object|NodeList} collection Collection of items to iterate
     * @param {Function} callback Callback function for each iteration
     * @param {Array|Object|NodeList} scope Object/NodeList/Array that forEach is iterating over (aka `this`)
     */
    var forEach = function (collection, callback, scope) {
        if (Object.prototype.toString.call(collection) === '[object Object]') {
            for (var prop in collection) {
                if (Object.prototype.hasOwnProperty.call(collection, prop)) {
                    callback.call(scope, collection[prop], prop, collection);
                }
            }
        } else {
            for (var i = 0, len = collection.length; i < len; i++) {
                callback.call(scope, collection[i], i, collection);
            }
        }
    };

    /**
     * Merge defaults with user options
     * @private
     * @param {Object} defaults Default settings
     * @param {Object} options User options
     * @returns {Object} Merged values of defaults and options
     */
    var extend = function () {

        // Variables
        var extended = {};
        var deep = false;
        var i = 0;
        var length = arguments.length;

        // Check if a deep merge
        if ( Object.prototype.toString.call( arguments[0] ) === '[object Boolean]' ) {
            deep = arguments[0];
            i++;
        }

        // Merge the object into the extended object
        var merge = function (obj) {
            for ( var prop in obj ) {
                if ( Object.prototype.hasOwnProperty.call( obj, prop ) ) {
                    // If deep merge and property is an object, merge properties
                    if ( deep && Object.prototype.toString.call(obj[prop]) === '[object Object]' ) {
                        extended[prop] = extend( true, extended[prop], obj[prop] );
                    } else {
                        extended[prop] = obj[prop];
                    }
                }
            }
        };

        // Loop through each object and conduct a merge
        for ( ; i < length; i++ ) {
            var obj = arguments[i];
            merge(obj);
        }

        return extended;

    };

    /**
     * Get the closest matching element up the DOM tree
     * @param {Element} elem Starting element
     * @param {String} selector Selector to match against (class, ID, or data attribute)
     * @return {Boolean|Element} Returns false if not match found
     */
    var getClosest = function ( elem, selector ) {

        // Variables
        var firstChar = selector.charAt(0);
        var attribute, value;

        // If selector is a data attribute, split attribute from value
        if ( firstChar === '[' ) {
            selector = selector.substr(1, selector.length - 2);
            attribute = selector.split( '=' );

            if ( attribute.length > 1 ) {
                value = true;
                attribute[1] = attribute[1].replace( /"/g, '' ).replace( /'/g, '' );
            }
        }

        // Get closest match
        for ( ; elem && elem !== document; elem = elem.parentNode ) {

            // If selector is a class
            if ( firstChar === '.' ) {
                if ( elem.classList.contains( selector.substr(1) ) ) {
                    return elem;
                }
            }

            // If selector is an ID
            if ( firstChar === '#' ) {
                if ( elem.id === selector.substr(1) ) {
                    return elem;
                }
            }

            // If selector is a data attribute
            if ( firstChar === '[' ) {
                if ( elem.hasAttribute( attribute[0] ) ) {
                    if ( value ) {
                        if ( elem.getAttribute( attribute[0] ) === attribute[1] ) {
                            return elem;
                        }
                    } else {
                        return elem;
                    }
                }
            }

            // If selector is a tag
            if ( elem.tagName.toLowerCase() === selector ) {
                return elem;
            }

        }

        return null;

    };

    /**
     * Stop YouTube, Vimeo, and HTML5 videos from playing when leaving the slide
     * @private
     * @param  {Element} content The content container the video is in
     * @param  {String} activeClass The class asigned to expanded content areas
     */
    var stopVideos = function ( content, activeClass ) {
        if ( !content.classList.contains( activeClass ) ) {
            var iframe = content.querySelector( 'iframe');
            var video = content.querySelector( 'video' );
            if ( iframe ) {
                var iframeSrc = iframe.src;
                iframe.src = iframeSrc;
            }
            if ( video ) {
                video.pause();
            }
        }
    };

    var bringFocus = function ( content, activeClass ) {
        if ( !content.classList.contains( activeClass ) ) return;
        content.focus();
        if ( document.activeElement.id !== content.id ) {
            content.setAttribute( 'tabindex', '-1' );
            content.focus();
        }
    };

    /**
     * Close all content areas in an expand/collapse group
     * @private
     * @param  {Element} toggle The element that toggled the expand or collapse
     * @param  {Object} settings
     */
    var closeCollapseGroup = function ( toggle, settings ) {
        if ( !toggle.classList.contains( settings.toggleActiveClass ) && toggle.hasAttribute('data-group') ) {

            // Get all toggles in the group
            var groupName = toggle.getAttribute('data-group');
            var group = document.querySelectorAll('[data-group="' + groupName + '"]');

            // Deactivate each toggle and it's content area
            forEach(group, function (item) {
                var content = document.querySelector( item.getAttribute('data-collapse') );
                item.classList.remove( settings.toggleActiveClass );
                content.classList.remove( settings.contentActiveClass );
            });

        }
    };

    /**
     * Toggle the collapse/expand widget
     * @public
     * @param  {Element} toggle The element that toggled the expand or collapse
     * @param  {String} contentID The ID of the content area to expand or collapse
     * @param  {Object} options
     * @param  {Event} event
     */
    houdini.toggleContent = function (toggle, contentID, options) {

        var settings = extend( settings || defaults, options || {} );  // Merge user options with defaults
        var content = document.querySelector(contentID); // Get content area

        // Toggle collapse element
        closeCollapseGroup(toggle, settings); // Close collapse group items
        toggle.classList.toggle( settings.toggleActiveClass );// Change text on collapse toggle
        content.classList.toggle( settings.contentActiveClass ); // Collapse or expand content area
        stopVideos( content, settings.contentActiveClass ); // If content area is closed, stop playing any videos
        bringFocus( content, settings.contentActiveClass ); // If content area is open, bring focus

        settings.callback( toggle, contentID ); // Run callbacks after toggling content

    };

    /**
     * Handle toggle click events
     * @private
     */
    var eventHandler = function (event) {
        var toggle = getClosest(event.target, settings.selector);
        if ( toggle ) {
            if ( toggle.tagName.toLowerCase() === 'a' || toggle.tagName.toLowerCase() === 'button' ) {
                event.preventDefault();
            }
            var contentID = toggle.hasAttribute('data-collapse') ? toggle.getAttribute('data-collapse') : toggle.parentNode.getAttribute('data-collapse');
            houdini.toggleContent( toggle, contentID, settings );
        }
    };

    /**
     * Destroy the current initialization.
     * @public
     */
    houdini.destroy = function () {
        if ( !settings ) return;
        document.documentElement.classList.remove( settings.initClass );
        document.removeEventListener('click', eventHandler, false);
        settings = null;
    };

    /**
     * Initialize Houdini
     * @public
     * @param {Object} options User settings
     */
    houdini.init = function ( options ) {

        // feature test
        if ( !supports ) return;

        // Destroy any existing initializations
        houdini.destroy();

        // Merge user options with defaults
        settings = extend( defaults, options || {} );

        // Add class to HTML element to activate conditional CSS
        document.documentElement.classList.add( settings.initClass );

        // Listen for all click events
        document.addEventListener('click', eventHandler, false);

    };


    //
    // Public APIs
    //

    return houdini;

});

Bake in hash support #a11y

  • Update hash when clicked
  • Allow people to right-click and open in a new tab
  • Automatically open the anchored content if hash exists and matches content

Forms in Firefox add extra space to the DOM

In Firefox, if the collapsed content has a <form> element in it, extra space is added to the DOM that should not be. One possible fix: use display: none instead of overflow: hidden, though this removes the nice fade-in/fade-out effect of the current approach.

Update NPM deps and switch to lib-sass

package.json

"gulp": "^3.9.0",
"node-fs": "^0.1.7",
"del": "^1.2.0",
"lazypipe": "^0.2.4",
"gulp-plumber": "^1.0.1",
"gulp-flatten": "^0.0.4",
"gulp-tap": "^0.1.3",
"gulp-rename": "^1.2.2",
"gulp-header": "^1.2.2",
"gulp-footer": "^1.0.5",
"gulp-watch": "^4.2.4",
"gulp-livereload": "^3.8.0",
"gulp-jshint": "^1.11.1",
"jshint-stylish": "^2.0.1",
"gulp-concat": "^2.6.0",
"gulp-uglify": "^1.2.0",
"karma": "^0.12.37",
"gulp-karma": "^0.0.4",
"karma-coverage": "^0.4.2",
"jasmine": "^2.3.1",
"karma-jasmine": "^0.3.6",
"karma-phantomjs-launcher": "^0.2.0",
"karma-spec-reporter": "^0.0.19",
"karma-htmlfile-reporter": "^0.2.1",
"gulp-sass": "^2.0.3",
"gulp-minify-css": "^1.2.0",
"gulp-autoprefixer": "^2.3.1",
"gulp-svgmin": "^1.1.2",
"gulp-svgstore": "^5.0.2",
"gulp-markdown": "^1.0.0",
"gulp-file-include": "^0.11.1"

gulpfile.js

.pipe(sass({
    outputStyle: 'expanded',
    sourceComments: true
}))

Move button labels to data attributes

Remove button labels from options and move to data attributes:

  • data-houdini-button for the button text
  • data-houdini-screenreader or data-houdini-label for ARIA label text

Height animation instead of opacity animation

Hi Chris--

It is a joy and pleasure to use Kraken and its cousin Keel, as well as the numerous other add-on modules you've created.

I had a question about this one: is it possible to animate the height (or I guess in this case, max-height) property of the element instetad of the opacity? I would prefer to have the content appear by sliding open instead of fading in.

Any thoughts?

Thanks for making all of your code open and available to developers like me!

initialising using JavaScript and the hash anchor reference.

Firstly, Houdini is my goto accordion. Thanks for your work and upkeep on this component. I am running into some issues on a project I am integrating this on and was hoping for some insight on how best to handle the the Hash anchor reference. The hash anchor appended to the URL eg: #collapse is causing some headaches when using Houdini as a Dropdown component. Is there a way to initialise and toggle the collapse via <button></button> using a [data-attribute] because Houdini uses a link eg: href="#collapse to toggle the collapse I am require to attach an eventListener and use history.pushState to remove the anchor tag reference, which is just not viable at all.

Ideally, the option to toggle and initialise using JavaScript opposed to using a <a href="#collapse"></a> would enable a must more flexible plugin.

houdini.closeContent

if use simple houdini.closeContent('#show') without toggle.

Uncaught TypeError: Cannot read property 'callbackClose' of undefined on line 306

toggle not defined in

settings.callbackClose(content, toggle)

Open/Close all section at once

Hi there and thank you so much to share this nice script.

I would like to add expand all and close all links in order to help speed up the process to open or close the information hided.

I haven't seen anything like it in your demos, would you be so kind to suggest me a way to do it?

Thank you so much and keep up the great work!

Target not focused correctly on accordions

Hi Chris, big fan of your very useful modules here!

I've been used Houdini quite a bit recently and I found a problem with the accordion configuration. To reproduce:

  • Create an accordion group with Houdini
  • Have a very long (height-wise) collapsible followed by a very short one
  • Have some content AFTER the accordion that will always overflow the viewport's height
  • Open the first (long) collapsible, all works fine
  • Open the second (short) collapsible, you'll end up seeing the content after Houdini (scroll position not set correctly)

Here's a reduced test case. Just click on "Section 2" and you'll end up in the middle of the "Long content after Houdini". Expected result would be to end up seeing the contents of Section 2.

Content flash - how to get rid of it?

Hi, how to get rid of content flash when the page first renders? Houdini doesn't kick in right away so the content first flashes before it's hidden.

transitions, animations

hey there,
quick question: i wanna animate the closing / opening, could you just give me an idea how to accomplish that? maybe including it in the demo would help!

thanks for all your truly great contributions to the open source world,

one love
daybugging

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    ๐Ÿ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. ๐Ÿ“Š๐Ÿ“ˆ๐ŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google โค๏ธ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.