Aller au contenu

Select

composant

A list of options to choose one from, submitted as a field of the form when it has a name. For a page without JavaScript, the select element is styled by phil-ux on its own.

Aperçu

Clair

Winter
Apple
Pear
Code
<twig:Select id="fruit" name="fruit">
    <twig:Select:Trigger><twig:Select:Value placeholder="Pick a fruit" /></twig:Select:Trigger>
    <twig:Select:Content>
        <twig:Select:Group id="winter">
            <twig:Select:Label>Winter</twig:Select:Label>
            <twig:Select:Item value="apple">Apple</twig:Select:Item>
            <twig:Select:Item value="pear" :disabled="true">Pear</twig:Select:Item>
        </twig:Select:Group>
    </twig:Select:Content>
</twig:Select>

Sombre

Winter
Apple
Pear
Code
<twig:Select id="fruit" name="fruit">
    <twig:Select:Trigger><twig:Select:Value placeholder="Pick a fruit" /></twig:Select:Trigger>
    <twig:Select:Content>
        <twig:Select:Group id="winter">
            <twig:Select:Label>Winter</twig:Select:Label>
            <twig:Select:Item value="apple">Apple</twig:Select:Item>
            <twig:Select:Item value="pear" :disabled="true">Pear</twig:Select:Item>
        </twig:Select:Group>
    </twig:Select:Content>
</twig:Select>

Essayer

Changez les propriétés ; le serveur rend l’exemple de nouveau.

Winter
Apple
Pear
<twig:Select id="fruit" name="fruit">
    <twig:Select:Trigger><twig:Select:Value placeholder="Pick a fruit" /></twig:Select:Trigger>
    <twig:Select:Content>
        <twig:Select:Group id="winter">
            <twig:Select:Label>Winter</twig:Select:Label>
            <twig:Select:Item value="apple">Apple</twig:Select:Item>
            <twig:Select:Item value="pear" :disabled="true">Pear</twig:Select:Item>
        </twig:Select:Group>
    </twig:Select:Content>
</twig:Select>

Variantes

disabled

Whether the select is disabled.

Winter
Apple
Pear
Winter
Apple
Pear
disabled="false"
Winter
Apple
Pear
Winter
Apple
Pear
disabled="true"

required

Whether a value must be selected before the form can be submitted.

Winter
Apple
Pear
Winter
Apple
Pear
required="false"
Winter
Apple
Pear
Winter
Apple
Pear
required="true"

open

Whether the select is open on initial render.

Winter
Apple
Pear
Winter
Apple
Pear
open="false"
Winter
Apple
Pear
Winter
Apple
Pear
open="true"

Installation

php bin/console ux:install select --kit=github.com/parler-haut-interagir-librement/ux-kit

Propriétés

PropriétéTypePar défautDescription
name string|null null Name of the form field carrying the selected value.
value string '' The value selected on initial render.
disabled bool false Whether the select is disabled.
required bool false Whether a value must be selected before the form can be submitted.
open bool false Whether the select is open on initial render.

Accessibility

The select-only combobox of WAI-ARIA: the arrows, Home, End, Enter and the first letters choose; Escape and a click outside close, on the popover API; a <label for> names the trigger.

Fichiers à copier

assets/controllers/select_controller.js
import { Controller } from '@hotwired/stimulus';

/**
 * Where a box goes against its trigger: on the side asked for, or on the other one when it does not fit there and the
 * other has more room; aligned along the cross axis, `start` and `end` following the reading direction, and pushed
 * back inside the viewport. Every value is in viewport pixels, as `getBoundingClientRect()` gives them.
 */
export function placement(trigger, box, viewport, { side = 'bottom', align = 'center', gap = 6, rtl = false } = {}) {
    const vertical = side === 'top' || side === 'bottom';
    const size = vertical ? box.height : box.width;
    const roomBefore = (vertical ? trigger.top : trigger.left) - gap;
    const roomAfter = (vertical ? viewport.height - trigger.bottom : viewport.width - trigger.right) - gap;
    const wantsBefore = side === 'top' || side === 'left';
    const flipped = wantsBefore
        ? size > roomBefore && roomAfter > roomBefore
        : size > roomAfter && roomBefore > roomAfter;
    const before = wantsBefore !== flipped;
    const main = before
        ? (vertical ? trigger.top : trigger.left) - gap - size
        : (vertical ? trigger.bottom : trigger.right) + gap;

    const crossStart = vertical ? trigger.left : trigger.top;
    const crossEnd = vertical ? trigger.right : trigger.bottom;
    const crossSize = vertical ? box.width : box.height;
    const edge = vertical && rtl ? ({ start: 'end', end: 'start' }[align] ?? align) : align;
    const cross =
        edge === 'start' ? crossStart : edge === 'end' ? crossEnd - crossSize : (crossStart + crossEnd - crossSize) / 2;
    const limit = (vertical ? viewport.width : viewport.height) - crossSize;
    const clamped = Math.min(Math.max(0, cross), Math.max(0, limit));
    const names = vertical ? ['top', 'bottom'] : ['left', 'right'];

    return {
        top: Math.round(vertical ? main : clamped),
        left: Math.round(vertical ? clamped : main),
        side: names[before ? 0 : 1],
    };
}

/** How long the letters typed in a row add up, in milliseconds. */
export const TYPEAHEAD = 500;

/** How far a scroll button moves the options, in pixels. */
const SCROLL_STEP = 96;

/** The first of `labels` starting with `typed`, ignoring case; -1 when none does. */
export function typeahead(labels, typed) {
    const start = typed.toLocaleLowerCase();

    return labels.findIndex((label) => label.toLocaleLowerCase().startsWith(start));
}

/*
 * A select-only combobox over a listbox on the popover API, the invoker opening it and the browser closing it on Escape
 * and on a click outside. The focus goes on the options; the chosen value is written in the hidden select of the form.
 */
export default class extends Controller {
    static targets = [
        'trigger',
        'value',
        'content',
        'viewport',
        'item',
        'hiddenSelect',
        'scrollUpButton',
        'scrollDownButton',
    ];

    static values = {
        open: { type: Boolean, default: false },
        value: { type: String, default: '' },
    };

    #connected = false;
    #focusOnOpen = false;
    #typed = '';
    #typedTimer = null;
    #follow = () => this.#place();

    connect() {
        this.#render();
        this.#apply();
        this.#connected = true;
    }

    disconnect() {
        this.#connected = false;
        this.#stopFollowing();
        clearTimeout(this.#typedTimer);
    }

    toggle(event) {
        if (event?.currentTarget?.hasAttribute('popovertarget')) {
            return;
        }

        this.openValue ? this.close() : this.open();
    }

    open({ focus = true } = {}) {
        this.#focusOnOpen = focus;
        this.openValue = true;
    }

    close({ focus = true } = {}) {
        this.openValue = false;

        if (focus && this.hasTriggerTarget) {
            this.triggerTarget.focus();
        }
    }

    sync(event) {
        const open = event.newState === 'open';

        if (open) {
            this.#place();
            this.onViewportScroll();
            window.addEventListener('scroll', this.#follow, { passive: true, capture: true });
            window.addEventListener('resize', this.#follow, { passive: true });
            this.#focusOnOpen ||= this.openValue === false;
        } else {
            this.#stopFollowing();
        }

        this.openValue = open;
    }

    openValueChanged() {
        if (!this.#connected) {
            return;
        }

        this.#apply();

        if (this.openValue && this.#focusOnOpen) {
            this.#focusOnOpen = false;
            const options = this.#enabled;
            (
                options.find((item) => item.dataset.value === this.valueValue) ??
                options[0] ??
                this.contentTarget
            ).focus();
        }
    }

    valueValueChanged() {
        if (this.#connected) {
            this.#render();
        }
    }

    selectItem(event) {
        const item = event.currentTarget;

        if (item.dataset.disabled !== 'true') {
            this.#choose(item);
        }
    }

    highlightItem(event) {
        if (event.currentTarget.dataset.disabled !== 'true') {
            event.currentTarget.focus({ preventScroll: true });
        }
    }

    unhighlightItem() {
        this.contentTarget.focus({ preventScroll: true });
    }

    onTriggerKeydown(event) {
        if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
            event.preventDefault();
            this.open();
        }
    }

    onContentKeydown(event) {
        const options = this.#enabled;
        const index = options.indexOf(event.target);
        const count = options.length;

        switch (event.key) {
            case 'ArrowDown':
                this.#move(event, options[(index + 1) % count]);
                break;
            case 'ArrowUp':
                this.#move(event, options[index <= 0 ? count - 1 : index - 1]);
                break;
            case 'Home':
                this.#move(event, options[0]);
                break;
            case 'End':
                this.#move(event, options[count - 1]);
                break;
            case 'Enter':
            case ' ':
                event.preventDefault();

                if (index >= 0) {
                    this.#choose(options[index]);
                }

                break;
            case 'Tab':
                this.close({ focus: false });
                break;
            default:
                this.#type(event, options);
        }
    }

    scrollUp() {
        this.viewportTarget.scrollBy({ top: -SCROLL_STEP });
    }

    scrollDown() {
        this.viewportTarget.scrollBy({ top: SCROLL_STEP });
    }

    onViewportScroll() {
        const viewport = this.viewportTarget;

        this.scrollUpButtonTargets.forEach((button) => {
            button.hidden = viewport.scrollTop <= 0;
        });
        this.scrollDownButtonTargets.forEach((button) => {
            button.hidden = viewport.scrollTop + viewport.clientHeight >= viewport.scrollHeight;
        });
    }

    get #enabled() {
        return this.itemTargets.filter((item) => item.dataset.disabled !== 'true');
    }

    #label(item) {
        return item.querySelector('[data-slot="select-item-text"]')?.textContent.trim() ?? '';
    }

    #move(event, item) {
        event.preventDefault();
        item?.focus();
    }

    #type(event, options) {
        if (event.key.length !== 1 || event.ctrlKey || event.metaKey || event.altKey) {
            return;
        }

        clearTimeout(this.#typedTimer);
        this.#typed += event.key;
        this.#typedTimer = setTimeout(() => {
            this.#typed = '';
        }, TYPEAHEAD);
        options[
            typeahead(
                options.map((item) => this.#label(item)),
                this.#typed,
            )
        ]?.focus();
    }

    #choose(item) {
        this.valueValue = item.dataset.value;
        this.dispatch('change', { detail: { value: this.valueValue, label: this.#label(item) }, bubbles: true });
        this.close();
    }

    #render() {
        const selected = this.itemTargets.find(
            (item) => item.dataset.value === this.valueValue && this.valueValue !== '',
        );

        for (const item of this.itemTargets) {
            item.setAttribute('aria-selected', String(item === selected));
            const indicator = item.querySelector('[data-slot="select-item-indicator"]');

            if (indicator) {
                indicator.hidden = item !== selected;
            }
        }

        for (const value of this.valueTargets) {
            value.textContent = selected ? this.#label(selected) : value.dataset.placeholder;
        }

        if (this.hasTriggerTarget) {
            this.triggerTarget.dataset.hasValue = String(this.valueValue !== '');
        }

        if (this.hasHiddenSelectTarget) {
            const option = this.hiddenSelectTarget.options[0];
            option.value = this.valueValue;
            option.textContent = selected ? this.#label(selected) : '';
            this.hiddenSelectTarget.value = this.valueValue;

            if (this.#connected) {
                this.hiddenSelectTarget.dispatchEvent(new Event('change', { bubbles: true }));
            }
        }
    }

    #apply() {
        const open = this.openValue;
        const state = open ? 'open' : 'closed';

        this.element.dataset.state = state;

        if (this.hasTriggerTarget) {
            this.triggerTarget.dataset.state = state;
            this.triggerTarget.setAttribute('aria-expanded', String(open));
        }

        if (this.hasContentTarget) {
            this.contentTarget.dataset.state = state;

            if (open !== this.contentTarget.matches(':popover-open')) {
                open ? this.contentTarget.showPopover() : this.contentTarget.hidePopover();
            }
        }
    }

    #place() {
        if (!this.hasTriggerTarget) {
            return;
        }

        const content = this.contentTarget;
        const { top, left, side } = placement(
            this.triggerTarget.getBoundingClientRect(),
            { width: content.offsetWidth, height: content.offsetHeight },
            { width: document.documentElement.clientWidth, height: document.documentElement.clientHeight },
            {
                side: content.dataset.side,
                align: content.dataset.align,
                rtl: getComputedStyle(content).direction === 'rtl',
            },
        );

        content.style.inset = `${top}px auto auto ${left}px`;
        content.dataset.placed = side;
    }

    #stopFollowing() {
        window.removeEventListener('scroll', this.#follow, { capture: true });
        window.removeEventListener('resize', this.#follow);
    }
}
templates/components/Select.html.twig
{%- props
    ## string Unique identifier set on the trigger, so a `<label for>` can point at it, and used to generate the internal Select IDs.
    id,
    ## string|null Name of the form field carrying the selected value.
    name = null,
    ## string The value selected on initial render.
    value = '',
    ## bool Whether the select is disabled.
    disabled = false,
    ## bool Whether a value must be selected before the form can be submitted.
    required = false,
    ## bool Whether the select is open on initial render.
    open = false
-%}
{%- do provide('select.value', value) -%}
{%- do provide('select.open', open) -%}
{%- do provide('select.disabled', disabled) -%}
{%- do provide('select.triggerId', id) -%}
{%- do provide('select.contentId', 'select-' ~ id ~ '-content') -%}
<div data-slot="select" data-state="{{ open ? 'open' : 'closed' }}" data-disabled="{{ disabled ? 'true' : 'false' }}" data-select-open-value="{{ open ? 'true' : 'false' }}" data-select-value-value="{{ value }}"{{ attributes.defaults({class: 'isDropdown', 'data-controller': 'select'}) }}>
{%- if name is not null -%}
<select name="{{ name }}" tabindex="-1" aria-hidden="true" data-select-target="hiddenSelect" class="isVisuallyHidden"{% if required %} required{% endif %}{% if disabled %} disabled{% endif %}><option value="{{ value }}"></option></select>
{%- endif -%}
{##- A `Select:Trigger` and a `Select:Content`. -#}
{%- block content %}{% endblock -%}
</div>
templates/components/Select/Content.html.twig
{%- props
    ## 'item-aligned'|'popper' How the content is placed: over the trigger on the selected item, or anchored to the trigger edge.
    position = 'item-aligned',
    ## 'top'|'bottom' Which side of the trigger the content appears on.
    side = 'bottom',
    ## 'start'|'center'|'end' Alignment of the content along the cross axis.
    align = 'start'
-%}
<div id="{{ inject('select.contentId') }}" popover role="listbox" tabindex="-1" aria-labelledby="{{ inject('select.triggerId') }}" data-slot="select-content" data-select-target="content" data-position="{{ position }}" data-side="{{ side }}" data-align="{{ align }}" data-state="{{ inject('select.open', false) ? 'open' : 'closed' }}"{{ attributes.defaults({class: 'isDropdownMenu isSelectContent', 'data-action': 'keydown->select#onContentKeydown toggle->select#sync'}) }}>
<div class="isSelectScroll" hidden aria-hidden="true" data-slot="select-scroll-up-button" data-select-target="scrollUpButton" data-action="click->select#scrollUp">&#9652;</div>
<div class="isSelectViewport" data-slot="select-viewport" data-select-target="viewport" data-action="scroll->select#onViewportScroll:passive">
{##- The options, typically `Select:Group`, `Select:Label`, `Select:Item` and `Select:Separator` components. -#}
{%- block content %}{% endblock -%}
</div>
<div class="isSelectScroll" hidden aria-hidden="true" data-slot="select-scroll-down-button" data-select-target="scrollDownButton" data-action="click->select#scrollDown">&#9662;</div>
</div>
templates/components/Select/Group.html.twig
{%- props
    ## string|null Unique identifier linking the group to its `Select:Label`, so the group is announced with a name.
    id = null
-%}
{%- do provide('selectGroup.labelId', id is null ? null : 'select-group-' ~ id ~ '-label') -%}
<div role="group"{% if id is not null %} aria-labelledby="select-group-{{ id }}-label"{% endif %} data-slot="select-group"{{ attributes.defaults({class: 'isSelectGroup'}) }}>
{##- The group options, typically a `Select:Label` followed by `Select:Item` components. -#}
{%- block content %}{% endblock -%}
</div>
templates/components/Select/Item.html.twig
{%- props
    ## string The value submitted when this item is selected.
    value,
    ## bool Whether the item is disabled.
    disabled = false
-%}
{%- set _selected = value is same as(inject('select.value', '')) -%}
<div role="option" tabindex="-1" data-slot="select-item" data-select-target="item" data-value="{{ value }}" data-disabled="{{ disabled ? 'true' : 'false' }}" aria-selected="{{ _selected ? 'true' : 'false' }}" aria-disabled="{{ disabled ? 'true' : 'false' }}"{{ attributes.defaults({class: 'isSelectItem', 'data-action': 'click->select#selectItem pointermove->select#highlightItem pointerleave->select#unhighlightItem'}) }}>
<span class="isSelectItemText" data-slot="select-item-text">
{##- The item label. -#}
{%- block content %}{% endblock -%}
</span>
<span class="isSelectIndicator" aria-hidden="true" data-slot="select-item-indicator"{% if not _selected %} hidden{% endif %}>&#10003;</span>
</div>
templates/components/Select/Label.html.twig
{%- set _select_group_label_id = inject('selectGroup.labelId', null) -%}
<div{% if _select_group_label_id is not null %} id="{{ _select_group_label_id }}"{% endif %} data-slot="select-label"{{ attributes.defaults({class: 'isSelectLabel'}) }}>
{##- The label text. -#}
{%- block content %}{% endblock -%}
</div>
templates/components/Select/Separator.html.twig
<div role="separator" aria-orientation="horizontal" data-slot="select-separator"{{ attributes.defaults({class: 'isSelectSeparator'}) }}></div>
templates/components/Select/Trigger.html.twig
{%- props
    ## 'default'|'sm' The trigger size.
    size = 'default'
-%}
{%- set _select_open = inject('select.open', false) -%}
<button type="button" role="combobox" id="{{ inject('select.triggerId') }}" popovertarget="{{ inject('select.contentId') }}" data-slot="select-trigger" data-select-target="trigger" data-size="{{ size }}" data-state="{{ _select_open ? 'open' : 'closed' }}" data-has-value="{{ inject('select.value', '') is empty ? 'false' : 'true' }}" aria-haspopup="listbox" aria-controls="{{ inject('select.contentId') }}" aria-expanded="{{ _select_open ? 'true' : 'false' }}"{% if inject('select.disabled', false) %} disabled{% endif %}{{ attributes.defaults({class: 'isSelectTrigger', 'data-action': 'click->select#toggle keydown->select#onTriggerKeydown'}) }}>
{##- The trigger content, typically a `Select:Value`. -#}
{%- block content %}{% endblock -%}
<span class="isSelectIcon" aria-hidden="true" data-slot="select-trigger-icon">&#9662;</span>
</button>
templates/components/Select/Value.html.twig
{%- props
    ## string Text shown while no option is selected.
    placeholder = ''
-%}
<span data-slot="select-value" data-select-target="value" data-placeholder="{{ placeholder }}"{{ attributes.defaults({class: 'isSelectValue'}) }}>{{ placeholder }}</span>