@llselect/angularjs API

The complete attribute surface of the @llselect/angularjs directives, one entry per attribute. Install and loading, the two name attributes, integration gotchas and the design rationale live in the README; this page is only the reference.

Two independent files (see Files):

<llselect-single>

Single selection. ng-model holds the chosen item itself, or the select as projection when the ll-options expression has one. Takes the shared attributes.

<llselect-single name="fruit" ng-model="picked" required ll-filterable="true"
  ll-options="f.id as f.name group by f.type disable when f.soldOut for f in fruits track by f.id">
</llselect-single>

<llselect-multiple>

Multiple selection. ng-model holds an array of chosen items (or of select as projections), and required treats [] as empty. Takes the shared attributes plus its own.

Shared attributes

Attributes of both <llselect-single> and <llselect-multiple>. Every entry opens with its binding mode in bold:

  • Expression: $eval'd against the scope ONCE at link time. llselect resolves its settings bag once at construction, so a later scope change does not move them; only the method-backed ll-disabled is watched (see the Gotchas). String values need their own quotes: ll-placeholder="'Pick one'".
  • Literal: plain attribute text.
  • Flag: acts by presence alone.
  • Event expression: evaluated on EACH event, inside a digest, like ng-click. Only ll-on-open / ll-on-close.

App-wide defaults for arrow / filterable / highlight / popupWidthPolicy / uiTranslationPack are set once via llselectConfigProvider; a per-element attribute always wins.

ng-model

Expression, required. The chosen item on <llselect-single>; the array of chosen items on <llselect-multiple>. With select as in ll-options, the projected value(s) instead.

ng-change

Expression. Runs on each committed user choice - exactly like on a native control, no ll-change needed.

  • These directives drive a real ngModel; ng-change is the standard $viewChangeListeners pipeline, which runs only on the $setViewValue path: a real user choice.
  • It does NOT fire on load, on a programmatic model write, or when a data reload drops the chosen item - the write-back gate keeps those out of the view-change path (see the Gotchas).
  • The same holds for everything else riding the ngModel pipeline: validators, $dirty, angular-validation.

ll-on-open

Event expression -> onOpen. Evaluated on each open, inside a digest, so scope writes show up at once.

  • Fires on every open that actually happens, user-driven or programmatic (instance().open()). A call that does not actually open the popup - already open, disabled, or the trigger scrolled out of view / clipped - does not fire.
  • An error thrown by the expression goes to $exceptionHandler, like ng-click; it never aborts the open.
  • This is the only way to observe opening from the directive: the core onOpen setting is construction-frozen and the directive owns it.

ll-on-close

Event expression -> onClose. Evaluated on each close, inside a digest.

  • Same rules as ll-on-open.
  • It also fires for the close that destroying the element runs (for example ng-if removing an open control): the popup did close, and AngularJS broadcasts $destroy before disabling the scope.
  • In that teardown case, writes to parent-owned state (vm.*, a service) persist; writes to the dying child scope itself are lost with it.

ll-options

ng-options grammar, required. Names each item's text, its identity and its model value in one line:

select as label group by group disable when disable for (key, value) in collection track by trackBy
ng-options clause llselect
label (group 2, else group 1) itemToStringFn
group by (group 3) itemToGroupKeyFn
disable when (group 4) itemDisabledFn
in collection (group 8) setItems(), via $watchCollection
track by (group 9) compareFn
select as (group 1, when as is present) no equivalent, by design
  • select as is the ngModel value projection: "what does this item become in the model". Deliberately no llselect setting - the projection belongs to the app, and ng-options is the app stating it. (Also why the core has no itemToValueFn: reusing itemToString would conflate display with identity, so switching uiTranslationPack to another language would change your submitted values.)
  • track by is a per-item hash; compareFn is pairwise equality. Same semantic, different shape: compareFn: (a, b) => sameValueZero(trackBy(a), trackBy(b)) - ===, plus NaN equals NaN, the core's default identity rule.
  • (key, value) in object collections throw - pass an array (see Not supported).
  • NG_OPTIONS_REGEXP and its 9 capture groups are copied verbatim from angular.js (MIT, (c) 2010-2020 Google LLC) into llselect-angularjs.js; nothing else from AngularJS is copied.

name

Literal. AngularJS form registration (myForm.<name>): myForm.$valid, myForm.<name>.$error.required and myForm.$dirty all work - with no native <select> and no hidden input.

  • The value is NOT posted by a plain form submit, and the package never generates a name for you.
  • AngularJS's name and HTML's name are unrelated mechanisms: The two name attributes in the README.

required

Flag. The ngModel required validator. On <llselect-multiple>, [] counts as empty via a $isEmpty override - AngularJS's default would let required silently pass on an empty multi-selection (see the Gotchas).

ll-disabled

Expression, watched -> setDisabled(). Disables the widget. The only watched attribute, because it maps to a method; the other attributes feed construction-time settings, read once.

Disabling must go through llselect's own setDisabled():

  • The state belongs on the inner TRIGGER element: setDisabled() sets aria-disabled / data-disabled (and manages tabindex) on the focusable combobox itself. A disabled="true" attribute would sit on this host element - not a form control, so the browser ignores it completely.
  • Hover must survive: llselect never uses the native disabled attribute, which suppresses pointer events. A disabled trigger stays hoverable (and focusable via the core focusableWhenDisabled setting), so a tooltip can still explain WHY it is disabled.

ng-disabled is the same trap one level up: it is one $watch whose only action is toggling that inert host attribute (angular.js:24530), so the markup looks applied while the widget stays fully interactive. See Not supported.

ll-placeholder

Expression -> placeholder. The trigger's empty-state text. A string, so it needs its own quotes: ll-placeholder="'Pick one'". Per-field copy, which is why it has no app-wide default.

ll-filterable

Expression -> filterable. Shows the search box. true / false / a predicate (items) => boolean.

ll-filter-fn

Expression -> filterFn. Custom match logic for the search box.

  • Evaluated once at link time to a function (item, query) => boolean.
  • query is the raw input: not trimmed, not lower-cased. Normalize it yourself.
  • Not called while the query is empty (an empty box shows every item).
  • Without it, the default filter is a case-insensitive substring match against the item text from ll-options - what is SHOWN is what matches.

A real case: items are {interfaceType: 'vlan', interfaceNo: '2'}, shown as VLAN 2. The default filter already matches vlan 2 (the shown text is the haystack). The custom fn below also matches the space-less vlan2:

$scope.ifaceText = function (i) { return i.interfaceType.toUpperCase() + ' ' + i.interfaceNo }
$scope.ifaceMatch = function (i, query) {
  function norm(s) { return String(s).toLowerCase().replace(/\s+/g, '') }
  return norm($scope.ifaceText(i)).includes(norm(query))
}
<llselect-single ng-model="picked" ll-filterable="true" ll-filter-fn="ifaceMatch"
  ll-options="ifaceText(i) for i in interfaces track by i.interfaceNo">
</llselect-single>

ll-clearable

Expression -> clearable. Shows the trigger's clear (x) button.

  • Clearing writes the empty value into ng-model: undefined (single) / [] (multiple), through the normal ng-change pipeline.
  • If the model must stay a plain string, add a real "none" item to ll-options instead of enabling this - the same pattern as a native <select> placeholder option.

ll-popup-width-policy

Expression -> popupWidthPolicy. 'fit-content' (llselect's default) / 'match-trigger'.

ll-arrow

Literal. The trigger arrow icon: chevron (default) / triangle / none.

  • The chevron default is this package being batteries-included, unlike the core (which ships no arrow so the app decides).
  • none opts out and leaves the slot to the theme.
  • A custom arrow means editing your copy of llselect-angularjs.js - which is what a copy-paste package is for.

ll-item-content-fn

Expression -> createItemContentElFn. Custom visible content for each option row.

  • Evaluated once at link time to a function (item) => HTMLElement | null.
  • null (for one item, or no attribute at all) = the plain item text from ll-options.
  • Runs per rendered row per render (open / filter / list change), entirely outside any digest. The element is NOT $compiled - no Angular directives or bindings inside; build plain DOM (document.createElement, or clone a <template>).
  • To highlight the filter matches, wrap your text with the core helper llselect.createHighlightedTextEl(text, query) and read the query via the controller's instance().getFilterQuery() - the per-keystroke re-render keeps the marks current. When the DEFAULT text is all you need, ll-highlight does that wiring for you.
  • The accessible name and the filter text stay owned by the label clause of ll-options no matter what you render (the library sets the option's aria-label from it).
  • On <llselect-multiple> the element renders beside the default checkbox icon; ll-checkboxes="false" hands it the whole row.
  • Need real per-row Angular templates? That is <ui-llselect> - one child scope and one $compile per row is exactly the trade it prices in.
$scope.renderRow = function (fruit) {
  var row = document.createElement('span')
  var icon = document.createElement('i')
  icon.className = 'mdi mdi-' + fruit.icon
  icon.setAttribute('aria-hidden', 'true')
  row.append(icon, ' ' + fruit.name)
  return row
}
<llselect-single ng-model="picked" ll-item-content-fn="renderRow"
  ll-options="f.name for f in fruits"></llselect-single>

ll-highlight

Expression -> boolean. Wraps each filter-query match in the default item text in a <mark> element (the core createHighlightedTextEl helper).

  • The marks re-render per keystroke and clear with the query.
  • Only the DEFAULT item text: combined with ll-item-content-fn it throws - a custom content fn owns its whole content, so call llselect.createHighlightedTextEl inside it instead.
  • On <llselect-multiple> the text beside the default checkbox gets the marks.
  • App-wide default: the highlight key of llselectConfigProvider.
  • The option's accessible name stays the plain ll-options text (core contract).

ll-trigger-content-fn

Expression -> createTriggerContentElFn. Custom visible content for the trigger, under the same rules as ll-item-content-fn (outside any digest, never $compile'd).

  • <llselect-single>: the function receives { chosenItem, items }; null = the default rendering (the chosen item's text, or the placeholder).
  • <llselect-multiple>: receives { chosenItems, items }; null = the count summary / tags. A returned element overrides both display modes.
  • The trigger does not mirror rich rows by itself - feeding this the same renderer as ll-item-content-fn is what does that (demo 7).

ll-aria-label

Literal -> ariaLabel. The accessible name. The field's name resolves by the FIRST set rung, mirroring the W3C accessible-name computation order:

  1. ll-aria-labelledby.
  2. ll-aria-label (this attribute).
  3. ll-label-el.
  4. None set: the field is unnamed - a WAI-ARIA 1.2 violation, warned once per page in the console.

ll-aria-labelledby

Literal -> ariaLabelledBy. Space-separated element id(s) of the visible label.

ll-label-el

Literal -> labelEl. The id of your external label element. Native <label for> cannot target these divs; this wires both halves of the label relationship:

  • The element names the field (aria-labelledby), and clicking it focuses the trigger - focus only, never open, native <label> behavior.
  • Resolved once at link time via getElementById; an unknown id throws (reported in the console, never silent).
  • Want only the accessible-name half? Use ll-aria-labelledby.

<llselect-multiple> only

ll-trigger-display

Expression -> triggerDisplay. 'count' (default) / 'tags' - quoted: ll-trigger-display="'tags'".

ll-tag-content-fn

Expression -> createTagContentElFn. Custom content for one tag chip in ll-trigger-display="'tags'" mode.

  • A function (item) => HTMLElement | null; null = the plain item text.
  • The library still owns the chip container, the remove (x) button, and the button's aria-label (Remove <label>).
  • The chip itself is a generic <span> that ARIA prohibits naming - for icon-only content include your own visually hidden text if the chip should be announced as more than its remove button.

ll-tag-remove-button-content-fn

Expression -> createTagRemoveButtonContentElFn. The decorative icon inside each tag's remove (x) button.

  • A function (item) => HTMLElement | SVGElement | null; null (the default) = the theme's CSS glyph draws the x.
  • The library always owns the button, its click, and its aria-label.

ll-choose-all-row

Expression -> chooseAllRow. A tri-state choose-all row as the first row of the popup; it gets the tri-state icon matching the row checkboxes plus the pack's counting text.

ll-hide-chosen-rows

Expression -> hideChosenRows. Chosen items' rows leave the popup list; unchoosing (e.g. removing a tag) puts them back.

  • Pairs naturally with ll-trigger-display="'tags'": the tags show what is chosen, the popup lists what is still choosable.
  • With the default checkbox rows, every rendered checkbox is unchecked by construction - consider ll-checkboxes="false".
  • With ll-choose-all-row, the row acts as "choose everything still listed" and disappears with the last listed row.
  • When every item is chosen, the popup shows the no-results message.

ll-checkboxes

Expression, default true. Whether <llselect-multiple> rows get this package's live checkbox icons.

  • On by default - the same batteries-included trade as the arrow.
  • ll-checkboxes="false" strips every checkbox visual, including the choose-all row's icon; that row then shows only the counting text, which is also the core's own default.
  • The core itself ships no icons and no default indicator; per item its answer is the subclass recipe (demo 5.4 / 5.5).
  • Single-select never gets checkboxes - a radio-like look would misstate multiplicity.

llselectConfigProvider

A house style set once, rather than repeated on 40 elements. Per-element ll-* attributes always win over it.

angular.module('app', ['llselect'])
  .config(['llselectConfigProvider', function (llselectConfigProvider) {
    llselectConfigProvider.defaults({
      arrow: 'chevron',          // 'chevron' | 'triangle' | 'none' | null (null = the chevron default)
      filterable: true,          // boolean, or a predicate (items) => boolean
      highlight: true,           // wrap filter matches in the default item text in <mark>
      popupWidthPolicy: 'match-trigger',  // llselect's own default is 'fit-content'
      uiTranslationPack: llselectI18n.zhTW,  // an llselect language pack
    })
  }])

defaults()

Takes the defaults bag above. Only settings that are app-wide by nature are accepted; placeholder is deliberately absent for the mirror-image reason - it IS per-field copy. An unknown key throws rather than being ignored, so a typo cannot silently do nothing.

arrow

'chevron' | 'triangle' | 'none' | null - null (the default) resolves to this package's batteries-included chevron; 'none' leaves the arrow slot to the theme. The app-wide default behind ll-arrow.

filterable

Boolean, or a predicate (items) => boolean. The app-wide default behind ll-filterable.

highlight

Boolean. The app-wide default behind ll-highlight: wrap each filter-query match in the default item text in <mark>.

popupWidthPolicy

'fit-content' (llselect's own default) / 'match-trigger'. The app-wide default behind ll-popup-width-policy.

uiTranslationPack

An llselect language pack (e.g. llselectI18n.zhTW). The clearest app-wide-by-nature case: an app picks its language once, and llselect's chrome strings are not per-field copy. Applied when a widget is built; to switch the language of live widgets, see Switching the UI language at runtime.

Reaching the instance from your own directive

Both directives publish a controller under their directive names (llselectSingle / llselectMultiple). An app-owned attribute directive on the same element can require it and drive the full llselect public API - the door for app-wide policies (a permission-driven disable, forced focusableWhenDisabled tooltips, ...).

  • require takes the array form or, since AngularJS 1.5, the named object form.
  • With ? the entry is null on elements that are not llselect - what keeps a generic directive safe on native form controls.

instance()

Returns the live LLSelectSingle / LLSelectMultiple. Late-bound: the widget is constructed at link time, AFTER controllers instantiate - call it from a $watch or event handler, never from a controller constructor. Before link it throws; it never returns null.

A generic permission-driven disable that works on llselect AND native form controls (this exact shape is pinned by a test):

angular.module('app').directive('ownDisabled', ['permissions', function (permissions) {
  return {
    restrict: 'A',
    require: { single: '?llselectSingle', multiple: '?llselectMultiple' },
    link: function (scope, element, attrs, ctrls) {
      var api = ctrls.single || ctrls.multiple // null on non-llselect elements
      scope.$watch(function () { return permissions.canEdit() }, function (ok) {
        if (api) {
          api.instance().setDisabled(!ok) // llselect: state lives on the trigger; see ll-disabled
        } else {
          element.prop('disabled', !ok) // native form controls
        }
      })
    },
  }
}])
<llselect-single own-disabled ng-model="vm.fruit" ll-options="f for f in vm.fruits"></llselect-single>
<input own-disabled type="text">

Switching the UI language at runtime

The core method setUiTranslationPack(pack) swaps llselect's own UI strings in place, so switching language needs no widget rebuild. The wiring belongs to the app, because the current language is app state; it uses the same require + instance() door.

This recipe integrates angular-translate and changes no call site (this exact shape is pinned by a test):

angular.module('app')
  .constant('LLSELECT_I18N_PACKS', { 'zh-TW': llselectI18n.zhTW, ja: llselectI18n.ja })
  .directive('llselectSingle', i18nPackSync('llselectSingle'))
  .directive('llselectMultiple', i18nPackSync('llselectMultiple'))

function i18nPackSync(ctrlName) {
  return ['$translate', 'LLSELECT_I18N_PACKS', function ($translate, PACKS) {
    return {
      restrict: 'E',
      require: ctrlName,
      link: function (scope, element, attrs, ctrl) {
        scope.$watch(function () { return $translate.use() }, function (langKey) {
          if (langKey) { ctrl.instance().setUiTranslationPack(PACKS[langKey] || {}) }
        })
      },
    }
  }]
}
  • A second directive registered under the SAME name decorates every matching element. AngularJS runs all directives sharing a name (angular.js 1.8.3, the hasDirectives registry).
  • $translate.use() with no argument returns the current language key (angular-translate 2.19.1, $translate.use). While the first language file is still loading it returns undefined, so the guard skips.
  • The $watch applies the pack for the initial language and again on every switch. It deregisters with the scope, so there is nothing to clean up.
  • When no pack matches the key, {} restores the English defaults. setUiTranslationPack merges its argument over English, never over the previously set pack.
  • An explicit ll-placeholder keeps winning over each new pack's triggerPlaceholder.
  • A pack set via llselectConfigProvider seeds widgets when they are built; this directive is what updates the ones already on screen.

<ui-llselect>

The migration bridge for an existing ui-select codebase (llselect-ui-select.js, module llselect.uiCompat; needs llselect-angularjs.js loaded too).

Migrating a call site, at a glance:

ui-select <ui-llselect>
Elements <ui-select> / <ui-select-match> / <ui-select-choices> rename to <ui-llselect> / <ui-llselect-match> / <ui-llselect-choices>
Item text does not exist in ui-select add ll-item-text on <ui-llselect-choices>
Template content, repeat, track by, group-by, multiple, ... as you wrote them unchanged - see What carries over
CSS ui-select themes an llselect theme; the bridge takes ui-select's MARKUP, not its CSS
  • Scoping rule: bridge what llselect has; ignore what it does not. Nothing is half-implemented to look compatible.
  • It always renders the chevron - every ui-select theme has a caret, so a bare trigger would read as broken.
  • How the bridge is built, and why it is not a full ui-select reimplementation: the README, DESIGN.md and SPEC.md.

What carries over

ui-select <ui-llselect>
<ui-select-match> template (single) <ui-llselect-match> -> createTriggerContentElFn
<ui-select-match> template (multiple) <ui-llselect-match> -> createTagContentElFn - ui-select ng-repeats this slot over $select.selected, so it is per chip, not per trigger
<ui-select-choices> template <ui-llselect-choices> -> createItemContentElFn, $compiled against a per-row child scope
repeat="p in people" setItems via $watchCollection
alias as item in source the ngModel projection, same role as ng-options' select as
track by compareFn
| filter: $select.search in the repeat filterFn. llselect owns the search box and asks per item, so the source expression is re-evaluated once per query and answers membership - your filter expression stays authoritative. No | filter: in the repeat = typing filters nothing, exactly as in ui-select
group-by itemToGroupKeyFn
ui-disable-choice itemDisabledFn
multiple LLSelectMultiple (+ triggerDisplay: 'tags')
search-enabled filterable. Defaults to true, following ui-select's default rather than llselect's false - it is ui-select's markup, so its defaults are what the call site expects
remove-selected (multiple only, as in ui-select) hideChosenRows. Defaults to true like ui-select (chosen items leave the dropdown), rather than llselect's false - same fidelity rule as search-enabled. Pass remove-selected="false" to keep chosen rows listed
placeholder, allow-clear (on <ui-select-match>) placeholder, clearable
on-select, on-remove derived from onChange by diffing against the previous set
ng-disabled / the disabled attribute setDisabled(), via attrs.$observe('disabled') - the exact mechanism ui-select itself uses, its string quirks included (a truthy string like interpolated "false" disables). The observed attribute stays inert on the host, so hover - and a why-tooltip - keep working while disabled
aria-labelledby / aria-label / title (on <ui-llselect>) ariaLabelledBy / ariaLabel - the field's accessible name. Precedence follows ARIA: aria-labelledby (forwarded verbatim, names the field by reference) wins over aria-label, which wins over title; title keeps ui-select parity (its templates feed their aria labels from it)
$select.selected, $select.search, $select.multiple published on each template's scope
$index from createItemEl(item, index)

Filtering

Your | filter: chain in repeat IS the custom filter - same syntax as ui-select, so a migrated call site keeps its matching behavior. The running case (same as ll-filter-fn): items are {interfaceType: 'vlan', interfaceNo: '2'}, shown as VLAN 2 via ll-item-text, and the space-less vlan2 must still match. ui-select style, that is a registered filter:

angular.module('app').filter('ifaceMatch', function () {
  function norm(s) { return String(s).toLowerCase().replace(/\s+/g, '') }
  return function (items, query) {
    if (!query) { return items }
    return (items || []).filter(function (i) {
      return norm(i.interfaceType + ' ' + i.interfaceNo).includes(norm(query))
    })
  }
})
<ui-llselect-choices repeat="i in vm.interfaces | ifaceMatch: $select.search" ll-item-text="ifaceText(i)">

Differences from real ui-select:

  • Cost: for TYPING, ui-select re-evaluates the repeat (filter included) on every digest, per row; the bridge evaluates it once per typed query and answers membership from the result. The bridge's items $watchCollection still evaluates the full expression (with an empty query) once per digest to detect collection changes - same order of work as ui-select's own watcher, so no regression, but not free either.
  • The search box is llselect's own; $select.search is still published to your templates (e.g. for | highlight:).
  • A bare | filter: $select.search matches each property separately, so a query spanning two fields (vlan 2) matches nothing - in real ui-select AND here. That is Angular's filter filter; write one like the above.
  • Async searching (refresh, refresh-delay, minimum-input-length, spinner-enabled) is not bridged: Not supported.

ll-item-text

Expression -> itemToStringFn. This attribute does not exist in ui-select - it is the ONE thing you add when migrating. llselect needs one string per item - the option's accessible name and the plain-text renderings (trigger fallback, template-less rows) - and ui-select's markup has no place that states it (its row content is template DOM).

Sits on <ui-llselect-choices>, written over the repeat variable:

<!-- ui-select, before -->
<ui-select ng-model="vm.person">
  <ui-select-match placeholder="Pick a person">{{$select.selected.name}}</ui-select-match>
  <ui-select-choices repeat="p in vm.people | filter: $select.search">
    <span>{{p.name}}</span> <small>{{p.role}}</small>
  </ui-select-choices>
</ui-select>

<!-- ui-llselect, after: the renamed elements + ll-item-text. Template content unchanged. -->
<ui-llselect ng-model="vm.person">
  <ui-llselect-match placeholder="Pick a person">{{$select.selected.name}}</ui-llselect-match>
  <ui-llselect-choices repeat="p in vm.people | filter: $select.search" ll-item-text="p.name">
    <span>{{p.name}}</span> <small>{{p.role}}</small>
  </ui-llselect-choices>
</ui-llselect>

Any of ui-select's template shapes carries over - plus one ui-select cannot do:

<!-- elements: full control of the row -->
<ui-llselect-choices repeat="p in vm.people | filter: $select.search" ll-item-text="p.name">
  <span>{{p.name}}</span> <small>{{p.role}}</small>
</ui-llselect-choices>

<!-- a bare text node - ui-select accepts this shape too (its transclusion
     appends every node, element or not) -->
<ui-llselect-choices repeat="p in vm.people | filter: $select.search" ll-item-text="p.name">
  {{p.name}} ({{p.role}})
</ui-llselect-choices>

<!-- no template at all: each row renders the ll-item-text string. Impossible
     in ui-select - there the template is the only source of item text. -->
<ui-llselect-choices repeat="p in vm.people | filter: $select.search" ll-item-text="p.name"></ui-llselect-choices>
  • The template and ll-item-text are separate channels: the template is what a row SHOWS, ll-item-text is the plain string (the accessible name + plain-text renderings). The bridge never derives one from the other - reading the rendered DOM back would put the role hint above into the option's spoken name.
  • Searching does NOT use it. The | filter: $select.search expression in repeat stays the filter, exactly as in ui-select (see What carries over): there is no single "search string" - your filter expression decides what matches (a bare | filter: matches every property of the item).
  • Without ll-item-text, an object item degrades to String(item) ("[object Object]").

Deliberate deviations

  • No scope: true. ui-select creates a child scope for <ui-select>, which silently shadows a non-dotted ng-model: ng-model="p" writes p onto the child and the parent never sees it. (That is the real reason ui-select's docs push ng-model="ctrl.p".) Every template <ui-llselect> compiles gets its own child scope anyway, so $select lives there instead and ng-model keeps the parent scope. Strictly better, and more compatible in practice.
  • The highlight filter is not provided. It is ui-select's, not llselect's, so the rule says do not bridge it. It is 8 lines; app.js copies it from ui-select (MIT) so the demo's templates work without loading ui-select. Copy it the same way if your templates use | highlight: $select.search. Its .ui-select-highlight class also needs ui-select's one CSS line (font-weight: bold) - theme CSS is not bridged either, so copy that rule too (the demo's style.css does).
  • allow-clear also works in multiple mode. Real ui-select renders the clear button only in single mode. Its match-multiple templates ignore the attribute, and the default is false (uiSelectMatchDirective.js:25). llselect's multiple does have a clear button, so the bridge honors the attribute there too. This is treated as a gap in ui-select, not a design choice: its own removeSelected carries the same kind of single-mode TODO. The default is unchanged - no attribute, no button. The only visible effect: a multiple that carried a dead allow-clear now gets a working x that clears the whole selection.
  • Without track by, items are compared with angular.equals. One compare rule; its consequences:
    • Multiple mode is ui-select's own comparison: _isItemSelected deep-compares (uiSelectController.js:332). A reload's structurally equal fresh object is the SAME item: one chip survives, the row stays hidden under remove-selected, and a second entry cannot be added.
    • Single mode uses the same compare, so aria-selected and open-focus survive a reload. With identity they silently dropped while the trigger kept showing the choice.
    • The shown selection re-renders from the list's fresh object (llselect swaps the stored reference); the model keeps its own object, like ui-select.
    • Items that reference each other (parent / child back-references) REQUIRE track by: angular.equals recurses with no cycle guard. In multiple mode ui-select's own comparison shares this hazard; in single mode the deep compare is this bridge's addition (the single-mode sub-bullet), so there the constraint is the bridge's own.
    • Structurally equal duplicates are not supported; each mode has its own symptom:
      • Multiple: a model that already holds duplicate entries renders one chip, and the first user change writes the deduplicated list back. ui-select renders every duplicate chip; the UI here can never create such a model.
      • Single: every row equal to the chosen item is marked selected whenever the list renders - and it renders on every popup open, each filter keystroke, new items, and any rerender(). If nothing is chosen, or something else is, no duplicate is marked. (A change that repaints one row while the popup stays open - the clear button, a model write - refreshes only the first equal row; see MEDIUM-77 below.)
      • Project-wide policy (resolved, MEDIUM-77): items must be UNIQUE under compareFn - it defines item identity and the selection is a set. The core setItems warns once per page for the default compareFn; these ui-select-bridge symptoms are the documented consequence of feeding duplicates and are the caller's to avoid. (The angular.equals compare is a custom compareFn, so the core's O(n) scan does not fire here - use track by with unique keys.)
    • Cost: the deep compare costs what ui-select pays per check; track by replaces it with a key compare.
  • allow-clear is read once, as a static value. Real ui-select $observes the attribute, so an interpolated allow-clear="{{vm.canClear}}" can flip at runtime. In llselect clearable is a construction-time setting (the settings-freeze rule), and the slot attributes are captured when the directive template is COMPILED - before any interpolation runs - so allow-clear="{{expr}}" can never work, and re-linking the same markup (ng-if) re-reads the same uninterpolated text. Write a literal allow-clear="true" (or the bare attribute). To flip clearability at runtime, switch between two literal templates: two <ui-llselect> blocks behind complementary ng-if expressions (AngularJS 1.x has no ng-else), each with its literal allow-clear value. ng-if creates a child scope, so the ng-model there must be dotted (vm.value) - a bare name would be shadowed.

Note

Rebuilding the highlight filter on the core createHighlightedTextEl helper was considered and rejected. A filter name is app-global, so the bridge's version would silently replace ui-select's filter everywhere the app uses it. The two also render differently: ui-select's filter never escapes the text, while the core helper escapes everything. Full reasoning is in DESIGN.md, under "Why the highlight filter is not rebuilt on the core helper".

Ignored attributes are listed under Not supported.

Not supported

Deliberate gaps. Each is reported or simply absent, never silently half-working.

  • What "reported" means here (verified, not assumed): a directive's throw never reaches your code - $compile's invokeLinkFn wraps every link function in its own try/catch and hands the error to $exceptionHandler (angular.js:11374), which by default logs it. So a bad ll-options does not crash the page; the widget simply never renders and the reason is in the console. Every AngularJS directive works this way, uiSelectMinErr included.

Both directive sets:

  • (key, value) in object collections. Pass an array.
  • Native <form> submission. See The two name attributes in the README.

llselect-angularjs.js:

  • Filters on the collection (ll-options="c for c in colors | filter:q"). Filter in your controller and let $watchCollection see the result. (<ui-llselect> does support | filter: inside repeat, because that is ui-select's own filtering mechanism.)
  • ng-disabled / a plain disabled attribute. Both only toggle the host's disabled attribute, which nothing here honors - the widget stays fully interactive while the markup claims otherwise. Use ll-disabled; its entry has the two reasons disabling must go through setDisabled().

llselect-ui-select.js - ignored attributes, because llselect has no such concept:

  • tagging, tagging-label, tagging-tokens (llselect never creates items).
  • refresh, refresh-delay, minimum-input-length, spinner-enabled (no async data-fetching API; root README, "No asynchronous data-fetching API").
  • sortable, limit, paste, append-to-body, close-on-select, theme.
  • $select members that take a row scope: isActive, isDisabled, isLocked, plus on-highlight and ui-lock-choice. See Why it is not a full ui-select reimplementation in the README.