@schukai/monster 4.150.8 → 4.151.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -313,6 +313,9 @@ const selectionVersionSymbol = Symbol("selectionVersion");
313
313
  const closeOnSelectAutoSymbol = Symbol("closeOnSelectAuto");
314
314
  const lastFilterValueSymbol = Symbol("lastFilterValue");
315
315
  const filterInputVersionSymbol = Symbol("filterInputVersion");
316
+ const createOptionElementSymbol = Symbol("createOptionElement");
317
+ const createOptionCandidateSymbol = Symbol("createOptionCandidate");
318
+ const createOptionEventsAttachedSymbol = Symbol("createOptionEventsAttached");
316
319
 
317
320
  /**
318
321
  * @private
@@ -422,6 +425,7 @@ const SELECT_VIEWPORT_PADDING = 12;
422
425
  * @fires monster-options-set this event is fired when the options are set
423
426
  * @fires monster-selection-removed
424
427
  * @fires monster-selection-cleared
428
+ * @fires monster-option-created
425
429
  */
426
430
  class Select extends CustomControl {
427
431
  /**
@@ -555,6 +559,7 @@ class Select extends CustomControl {
555
559
  * @property {string} labels.select-an-option - Placeholder text when no selection has been made.
556
560
  * @property {string} labels.no-options - Message displayed when no options are available from slots or fetch.
557
561
  * @property {string} labels.no-options-found - Message displayed when the filter yields no matching options.
562
+ * @property {string} labels.create-option - Template for the create action. `${label}` and `${value}` are replaced with the candidate data.
558
563
  * @property {string} labels.summary-text.zero - Pluralization template for zero selected entries (e.g., "No entries selected").
559
564
  * @property {string} labels.summary-text.one - Pluralization template for one selected entry.
560
565
  * @property {string} labels.summary-text.other - Pluralization template for multiple selected entries.
@@ -567,6 +572,7 @@ class Select extends CustomControl {
567
572
  * @property {boolean} features.storeFetchedData - If `true`, the raw fetched data is stored and can be retrieved via `getLastFetchedData()`.
568
573
  * @property {boolean} features.useStrictValueComparison - Uses strict comparison (`===`) when matching option values.
569
574
  * @property {boolean} features.showRemoteInfo - When `filter.mode === "remote"`, displays an info badge indicating that more options may exist on the server.
575
+ * @property {boolean} features.createOption - Shows an action for creating and selecting a missing filtered option.
570
576
  * @property {Object} remoteInfo - Configuration for the remote info display.
571
577
  * @property {string|null} remoteInfo.url - URL to fetch the total count of remote options (used when `filter.mode === "remote"`).
572
578
  * @property {Object} placeholder - Placeholder texts for input fields.
@@ -601,12 +607,14 @@ class Select extends CustomControl {
601
607
  * @property {Array} empty.equivalents - Values that are considered "empty" (e.g., `undefined`, `null`, `""`, `NaN`) and are normalized to the default value. Matching is type-safe: a configured string `"0"` only matches the string `"0"`; configure numeric `0` explicitly, via `data-monster-options` JSON, `data-monster-properties`, or `setOption()`, when the number `0` should be treated as empty.
602
608
  * @property {Object} formatter - Functions for formatting display values.
603
609
  * @property {Function} formatter.selection - Callback `(value, option) => string` to format the display text of selected values.
610
+ * @property {Function} formatter.createOption - Callback `(filterValue) => {label, value}|null` used to build a missing option. Return `null` to suppress creation.
604
611
  * @property {Object} classes - CSS classes for various elements.
605
612
  * @property {string} classes.badge - CSS class for selection badges.
606
613
  * @property {string} classes.badgeUnresolved - CSS class used for selection badges whose value could not be hydrated. By default this swaps the badge into a warning style while keeping the raw key visible.
607
614
  * @property {string} classes.statusOrRemoveBadge - CSS class for the status or remove badge.
608
615
  * @property {string} classes.remoteInfo - CSS class for the remote info badge.
609
616
  * @property {string} classes.noOptions - CSS class for the "no options" message.
617
+ * @property {string} classes.createOption - Badge class for the create-option action.
610
618
  * @property {Object} messages - Internal messages for ARIA attributes and screen readers (should not normally be changed).
611
619
  * @property {string|null} messages.control - Message for the main control element.
612
620
  * @property {string|null} messages.selected - Message for the selected items area.
@@ -634,6 +642,7 @@ class Select extends CustomControl {
634
642
  storeFetchedData: false,
635
643
  useStrictValueComparison: false,
636
644
  showRemoteInfo: true,
645
+ createOption: false,
637
646
  },
638
647
 
639
648
  placeholder: {
@@ -689,6 +698,7 @@ class Select extends CustomControl {
689
698
  statusOrRemoveBadge: "empty",
690
699
  remoteInfo: "monster-margin-start-4 monster-margin-top-4",
691
700
  noOptions: "monster-margin-top-4 monster-margin-start-4",
701
+ createOption: "monster-badge-primary",
692
702
  },
693
703
 
694
704
  mapping: {
@@ -710,6 +720,7 @@ class Select extends CustomControl {
710
720
 
711
721
  formatter: {
712
722
  selection: buildSelectionLabel,
723
+ createOption: buildCreatedOption,
713
724
  },
714
725
 
715
726
  templates: {
@@ -1500,6 +1511,7 @@ function getTranslations() {
1500
1511
  case "de":
1501
1512
  return {
1502
1513
  "cannot-be-loaded": "Kann nicht geladen werden",
1514
+ "create-option": "„${label}“ hinzufügen",
1503
1515
  "no-options-available": "Keine Optionen verfügbar.",
1504
1516
  "click-to-load-options": "Klicken, um Optionen zu laden.",
1505
1517
  "select-an-option": "Option auswählen",
@@ -1957,6 +1969,7 @@ function getTranslations() {
1957
1969
  // Fallback to English if locale.language is unrecognized
1958
1970
  return {
1959
1971
  "cannot-be-loaded": "Cannot be loaded",
1972
+ "create-option": "Add “${label}”",
1960
1973
  "no-options-available": "No options available.",
1961
1974
  "click-to-load-options": "Click to load options.",
1962
1975
  "select-an-option": "Select an option",
@@ -2974,6 +2987,300 @@ function buildSelectionItem(value, preferredLabel) {
2974
2987
  };
2975
2988
  }
2976
2989
 
2990
+ /**
2991
+ * @private
2992
+ * @param {string} filterValue
2993
+ * @returns {{label: string, value: string}}
2994
+ */
2995
+ function buildCreatedOption(filterValue) {
2996
+ return {
2997
+ label: filterValue,
2998
+ value: filterValue,
2999
+ };
3000
+ }
3001
+
3002
+ /**
3003
+ * @private
3004
+ * @returns {string}
3005
+ */
3006
+ function getRawFilterInputValue() {
3007
+ const element =
3008
+ this.getOption("filter.position") === FILTER_POSITION_INLINE
3009
+ ? this[inlineFilterElementSymbol]
3010
+ : this[popperFilterElementSymbol];
3011
+
3012
+ return element instanceof HTMLInputElement ? element.value.trim() : "";
3013
+ }
3014
+
3015
+ /**
3016
+ * @private
3017
+ * @param {string} filterValue
3018
+ * @returns {{label: string, value: *}|null}
3019
+ */
3020
+ function buildCreateOptionCandidate(filterValue) {
3021
+ const formatter = this.getOption("formatter.createOption");
3022
+ if (!isFunction(formatter)) {
3023
+ return null;
3024
+ }
3025
+
3026
+ const result = formatter.call(this, filterValue);
3027
+ if (result === null || result === undefined) {
3028
+ return null;
3029
+ }
3030
+
3031
+ const candidate = isObject(result)
3032
+ ? { ...result }
3033
+ : { label: String(result), value: result };
3034
+ if (candidate.value === undefined) {
3035
+ candidate.value = candidate.label;
3036
+ }
3037
+ if (candidate.label === undefined) {
3038
+ candidate.label = getSelectionValueLabel.call(this, candidate.value);
3039
+ }
3040
+
3041
+ candidate.label = String(candidate.label).trim();
3042
+ if (candidate.label === "" || isValueIsEmpty.call(this, candidate.value)) {
3043
+ return null;
3044
+ }
3045
+
3046
+ return candidate;
3047
+ }
3048
+
3049
+ /**
3050
+ * @private
3051
+ * @param {{label: string, value: *}} candidate
3052
+ * @param {string} filterValue
3053
+ * @returns {boolean}
3054
+ */
3055
+ function hasEquivalentCreateOption(candidate, filterValue) {
3056
+ const normalizedFilter = filterValue.toLocaleLowerCase();
3057
+ const options = this.getOption("options", []);
3058
+ if (!isArray(options)) {
3059
+ return false;
3060
+ }
3061
+
3062
+ return options.some((option) => {
3063
+ const value = isObject(option) ? option.value : option;
3064
+ const label = isObject(option) ? option.label : option;
3065
+ const normalizedLabel = String(label ?? "")
3066
+ .trim()
3067
+ .toLocaleLowerCase();
3068
+
3069
+ return (
3070
+ normalizedLabel === normalizedFilter ||
3071
+ areLookupValuesEqual.call(this, value, candidate.value)
3072
+ );
3073
+ });
3074
+ }
3075
+
3076
+ /**
3077
+ * @private
3078
+ * @returns {HTMLElement|null}
3079
+ */
3080
+ function ensureCreateOptionElement() {
3081
+ if (!this.shadowRoot) {
3082
+ return null;
3083
+ }
3084
+
3085
+ const element = this.shadowRoot.querySelector(
3086
+ `[${ATTRIBUTE_ROLE}="create-option"]`,
3087
+ );
3088
+ if (!(element instanceof HTMLElement)) {
3089
+ return null;
3090
+ }
3091
+
3092
+ if (this[createOptionEventsAttachedSymbol] !== true) {
3093
+ const activate = (event) => {
3094
+ if (this.getOption("disabled") === true) {
3095
+ return;
3096
+ }
3097
+ event.preventDefault();
3098
+ createFilteredOption.call(this).catch((error) => {
3099
+ addErrorAttribute(this, error);
3100
+ });
3101
+ };
3102
+ const types = new Set(["click", ...this.getOption("toggleEventType", [])]);
3103
+ for (const type of types) {
3104
+ element.addEventListener(type, activate);
3105
+ }
3106
+ this[createOptionEventsAttachedSymbol] = true;
3107
+ }
3108
+
3109
+ this[createOptionElementSymbol] = element;
3110
+ return element;
3111
+ }
3112
+
3113
+ /**
3114
+ * @private
3115
+ */
3116
+ function updateCreateOptionAction() {
3117
+ const element = ensureCreateOptionElement.call(this);
3118
+ if (!(element instanceof HTMLElement)) {
3119
+ return;
3120
+ }
3121
+
3122
+ let candidate = null;
3123
+ const filterValue = getRawFilterInputValue.call(this);
3124
+ if (
3125
+ this.getOption("features.createOption") === true &&
3126
+ this.getOption("disabled") !== true &&
3127
+ getFilterMode.call(this) !== FILTER_MODE_DISABLED &&
3128
+ filterValue !== ""
3129
+ ) {
3130
+ try {
3131
+ candidate = buildCreateOptionCandidate.call(this, filterValue);
3132
+ if (
3133
+ candidate &&
3134
+ hasEquivalentCreateOption.call(this, candidate, filterValue)
3135
+ ) {
3136
+ candidate = null;
3137
+ }
3138
+ } catch (error) {
3139
+ addErrorAttribute(this, error);
3140
+ }
3141
+ }
3142
+
3143
+ this[createOptionCandidateSymbol] = candidate;
3144
+ if (!candidate) {
3145
+ element.setAttribute(`${ATTRIBUTE_PREFIX}visibility`, "hidden");
3146
+ element.removeAttribute(`${ATTRIBUTE_PREFIX}focused`);
3147
+ return;
3148
+ }
3149
+
3150
+ const template = this.getOption("labels.create-option", "Add “${label}”");
3151
+ const text = new Formatter({
3152
+ label: candidate.label,
3153
+ value: getSelectionValueLabel.call(this, candidate.value),
3154
+ }).format(template);
3155
+ const button = element.querySelector("button");
3156
+ const label = element.querySelector(
3157
+ `[${ATTRIBUTE_ROLE}="create-option-label"]`,
3158
+ );
3159
+ if (button instanceof HTMLButtonElement) {
3160
+ button.className = this.getOption(
3161
+ "classes.createOption",
3162
+ "monster-badge-primary",
3163
+ );
3164
+ button.setAttribute("aria-label", text);
3165
+ }
3166
+ element.setAttribute("aria-label", text);
3167
+ if (label instanceof HTMLElement) {
3168
+ label.textContent = text;
3169
+ }
3170
+ element.setAttribute(`${ATTRIBUTE_PREFIX}visibility`, "visible");
3171
+ }
3172
+
3173
+ /**
3174
+ * Waits until the updater has projected a newly appended option.
3175
+ *
3176
+ * @private
3177
+ * @param {*} value
3178
+ * @returns {Promise<HTMLInputElement>}
3179
+ */
3180
+ function waitForCreatedOptionControl(value) {
3181
+ return new Promise((resolve, reject) => {
3182
+ const startedAt = Date.now();
3183
+ const find = () => {
3184
+ for (const input of this[optionsElementSymbol].querySelectorAll(
3185
+ `[${ATTRIBUTE_ROLE}="option-control"]`,
3186
+ )) {
3187
+ if (
3188
+ input instanceof HTMLInputElement &&
3189
+ input.value === String(value)
3190
+ ) {
3191
+ return input;
3192
+ }
3193
+ }
3194
+ return null;
3195
+ };
3196
+ const poll = () => {
3197
+ const input = find();
3198
+ if (input) {
3199
+ resolve(input);
3200
+ return;
3201
+ }
3202
+ if (Date.now() - startedAt >= 1000) {
3203
+ reject(new Error("Created option was not rendered."));
3204
+ return;
3205
+ }
3206
+ setTimeout(poll, 10);
3207
+ };
3208
+ poll();
3209
+ });
3210
+ }
3211
+
3212
+ /**
3213
+ * @private
3214
+ * @returns {Promise<boolean>}
3215
+ */
3216
+ async function createFilteredOption() {
3217
+ updateCreateOptionAction.call(this);
3218
+ const candidate = this[createOptionCandidateSymbol];
3219
+ if (!candidate) {
3220
+ return false;
3221
+ }
3222
+
3223
+ const options = this.getOption("options", []);
3224
+ const nextOptions = (isArray(options) ? options : []).map((option) => {
3225
+ return isObject(option) ? { ...option, filtered: undefined } : option;
3226
+ });
3227
+ nextOptions.push({
3228
+ ...candidate,
3229
+ visibility: candidate.visibility ?? "visible",
3230
+ filtered: undefined,
3231
+ });
3232
+
3233
+ for (const input of [
3234
+ this[inlineFilterElementSymbol],
3235
+ this[popperFilterElementSymbol],
3236
+ ]) {
3237
+ if (input instanceof HTMLInputElement) {
3238
+ input.value = "";
3239
+ }
3240
+ }
3241
+ this[lastFilterValueSymbol] = "";
3242
+ bumpFilterInputVersion.call(this);
3243
+ this[createOptionCandidateSymbol] = null;
3244
+ this[createOptionElementSymbol]?.setAttribute(
3245
+ `${ATTRIBUTE_PREFIX}visibility`,
3246
+ "hidden",
3247
+ );
3248
+
3249
+ this.setOption("options", nextOptions);
3250
+ fireCustomEvent(this, "monster-options-set", { options: nextOptions });
3251
+
3252
+ const createdInput = await waitForCreatedOptionControl.call(
3253
+ this,
3254
+ candidate.value,
3255
+ );
3256
+ await new Promise((resolve) => setTimeout(resolve, 0));
3257
+ const selection =
3258
+ this.getOption("type") === "checkbox"
3259
+ ? [...this.getOption("selection", []), candidate]
3260
+ : [candidate];
3261
+ const selectedValues = new Set(
3262
+ selection.map((item) => String(isObject(item) ? item.value : item)),
3263
+ );
3264
+ for (const input of this[optionsElementSymbol].querySelectorAll(
3265
+ `[${ATTRIBUTE_ROLE}="option-control"]`,
3266
+ )) {
3267
+ if (input instanceof HTMLInputElement) {
3268
+ input.checked = selectedValues.has(input.value);
3269
+ }
3270
+ }
3271
+ createdInput.checked = true;
3272
+ await setSelection.call(this, selection);
3273
+ fireCustomEvent(this, "monster-option-created", { option: candidate });
3274
+
3275
+ if (this.getOption("features.closeOnSelect") === true) {
3276
+ hide.call(this);
3277
+ } else {
3278
+ focusFilter.call(this, { preventScroll: true });
3279
+ }
3280
+
3281
+ return true;
3282
+ }
3283
+
2977
3284
  /**
2978
3285
  * @private
2979
3286
  * @param {string} value The value to look up.
@@ -3234,6 +3541,7 @@ function initOptionObserver() {
3234
3541
  }
3235
3542
 
3236
3543
  setSummaryAndControlText.call(self);
3544
+ updateCreateOptionAction.call(self);
3237
3545
  }).run();
3238
3546
  }),
3239
3547
  );
@@ -3351,6 +3659,34 @@ function getOptionElements() {
3351
3659
  );
3352
3660
  }
3353
3661
 
3662
+ /**
3663
+ * @private
3664
+ * @returns {HTMLElement[]}
3665
+ */
3666
+ function getNavigableOptionElements() {
3667
+ if (!(this[optionsElementSymbol] instanceof HTMLElement)) {
3668
+ return [];
3669
+ }
3670
+
3671
+ const elements = Array.from(
3672
+ this[optionsElementSymbol].querySelectorAll(`[${ATTRIBUTE_ROLE}=option]`),
3673
+ );
3674
+ const createOption = this.shadowRoot?.querySelector?.(
3675
+ `[${ATTRIBUTE_ROLE}="create-option"]`,
3676
+ );
3677
+ if (createOption instanceof HTMLElement) {
3678
+ elements.push(createOption);
3679
+ }
3680
+
3681
+ return elements.filter((element) => {
3682
+ return (
3683
+ element instanceof HTMLElement &&
3684
+ element.getAttribute(`${ATTRIBUTE_PREFIX}visibility`) === "visible" &&
3685
+ element.getAttribute(`${ATTRIBUTE_PREFIX}filtered`) !== "true"
3686
+ );
3687
+ });
3688
+ }
3689
+
3354
3690
  /**
3355
3691
  * With the help of this filter callback, values can be filtered out. Only if the filter function returns true, the value is taken for the map.
3356
3692
  *
@@ -3372,11 +3708,14 @@ function getOptionElements() {
3372
3708
  * @private
3373
3709
  */
3374
3710
  function calcAndSetOptionsDimension() {
3375
- const options = getOptionElements.call(this);
3711
+ const options = Array.from(getOptionElements.call(this));
3712
+ const createOption = this.shadowRoot?.querySelector?.(
3713
+ `[${ATTRIBUTE_ROLE}="create-option"][${ATTRIBUTE_PREFIX}visibility="visible"]`,
3714
+ );
3376
3715
  const container = this[optionsElementSymbol];
3377
3716
  const content =
3378
3717
  this[popperElementSymbol]?.querySelector?.('[part="content"]');
3379
- if (!(container instanceof HTMLElement && options instanceof NodeList)) {
3718
+ if (!(container instanceof HTMLElement && isArray(options))) {
3380
3719
  return;
3381
3720
  }
3382
3721
 
@@ -3395,7 +3734,7 @@ function calcAndSetOptionsDimension() {
3395
3734
  visible++;
3396
3735
  }
3397
3736
 
3398
- if (visible === 0) {
3737
+ if (visible === 0 && !(createOption instanceof HTMLElement)) {
3399
3738
  if (this.getOption("classes.statusOrRemoveBadge") === "error") {
3400
3739
  this.setOption(
3401
3740
  "messages.emptyOptions",
@@ -3442,11 +3781,21 @@ function calcAndSetOptionsDimension() {
3442
3781
  geometry.availableHeight,
3443
3782
  SELECT_MAX_POPPER_HEIGHT,
3444
3783
  );
3445
- const listAvailableHeight = resolveSelectListAvailableHeight({
3784
+ let listAvailableHeight = resolveSelectListAvailableHeight({
3446
3785
  popperElement: this[popperElementSymbol],
3447
3786
  contentElement: content,
3448
3787
  popperMaxHeight,
3449
3788
  });
3789
+ if (createOption instanceof HTMLElement) {
3790
+ const createStyle = getGlobal().getComputedStyle(createOption);
3791
+ listAvailableHeight = Math.max(
3792
+ 0,
3793
+ listAvailableHeight -
3794
+ createOption.getBoundingClientRect().height -
3795
+ readSelectBoxDimension(createStyle.marginTop) -
3796
+ readSelectBoxDimension(createStyle.marginBottom),
3797
+ );
3798
+ }
3450
3799
  const listDimension = resolveSelectListDimension({
3451
3800
  visibleOptionHeights,
3452
3801
  maxVisibleOptions: max,
@@ -3887,54 +4236,24 @@ function activateCurrentOption(direction) {
3887
4236
  throw new Error("no shadow-root is defined");
3888
4237
  }
3889
4238
 
3890
- let focused = this.shadowRoot.querySelector(`[${ATTRIBUTE_PREFIX}focused]`);
3891
-
3892
- if (
3893
- !(focused instanceof HTMLElement) ||
3894
- focused.matches("[data-monster-visibility=hidden]")
3895
- ) {
3896
- for (const [, e] of Object.entries(
3897
- this.shadowRoot.querySelectorAll(`[${ATTRIBUTE_ROLE}=option]`),
3898
- )) {
3899
- if (e.matches("[data-monster-visibility=visible]")) {
3900
- focused = e;
3901
- break;
3902
- }
3903
- }
4239
+ const options = getNavigableOptionElements.call(this);
4240
+ const current = this.shadowRoot.querySelector(`[${ATTRIBUTE_PREFIX}focused]`);
4241
+ const currentIndex = options.indexOf(current);
4242
+ let focused = null;
4243
+ if (currentIndex === -1) {
4244
+ focused = direction === FOCUS_DIRECTION_UP ? options.at(-1) : options[0];
4245
+ } else if (direction === FOCUS_DIRECTION_DOWN) {
4246
+ focused = options[currentIndex + 1] ?? current;
4247
+ } else if (currentIndex > 0) {
4248
+ focused = options[currentIndex - 1];
3904
4249
  } else {
3905
- if (direction === FOCUS_DIRECTION_DOWN) {
3906
- while (focused.nextSibling) {
3907
- focused = focused.nextSibling;
3908
-
3909
- if (
3910
- focused instanceof HTMLElement &&
3911
- focused.hasAttribute(ATTRIBUTE_ROLE) &&
3912
- focused.getAttribute(ATTRIBUTE_ROLE) === "option" &&
3913
- focused.matches("[data-monster-visibility=visible]") &&
3914
- focused.matches(":not([data-monster-filtered=true])")
3915
- ) {
3916
- break;
3917
- }
3918
- }
3919
- } else {
3920
- let found = false;
3921
- while (focused.previousSibling) {
3922
- focused = focused.previousSibling;
3923
- if (
3924
- focused instanceof HTMLElement &&
3925
- focused.hasAttribute(ATTRIBUTE_ROLE) &&
3926
- focused.getAttribute(ATTRIBUTE_ROLE) === "option" &&
3927
- focused.matches("[data-monster-visibility=visible]") &&
3928
- focused.matches(":not([data-monster-filtered=true])")
3929
- ) {
3930
- found = true;
3931
- break;
3932
- }
3933
- }
3934
- if (found === false) {
3935
- focusFilter.call(this, { preventScroll: true });
3936
- }
3937
- }
4250
+ this.shadowRoot
4251
+ .querySelectorAll(`[${ATTRIBUTE_PREFIX}focused]`)
4252
+ .forEach((element) => {
4253
+ element.removeAttribute(`${ATTRIBUTE_PREFIX}focused`);
4254
+ });
4255
+ focusFilter.call(this, { preventScroll: true });
4256
+ return;
3938
4257
  }
3939
4258
 
3940
4259
  new Processing(() => {
@@ -4017,6 +4336,15 @@ function handleFilterKeyboardEvents(event) {
4017
4336
  const shiftKey = event?.["shiftKey"];
4018
4337
 
4019
4338
  switch (event?.["code"]) {
4339
+ case "Enter":
4340
+ updateCreateOptionAction.call(this);
4341
+ if (this[createOptionCandidateSymbol]) {
4342
+ createFilteredOption.call(this).catch((error) => {
4343
+ addErrorAttribute(this, error);
4344
+ });
4345
+ event.preventDefault();
4346
+ }
4347
+ break;
4020
4348
  case "Tab":
4021
4349
  activateCurrentOption.call(this, FOCUS_DIRECTION_DOWN);
4022
4350
  event.preventDefault();
@@ -4053,6 +4381,7 @@ function handleFilterKeyboardEvents(event) {
4053
4381
  * @return {void} This method does not return anything.
4054
4382
  */
4055
4383
  function handleFilterInputEvents() {
4384
+ updateCreateOptionAction.call(this);
4056
4385
  const filterValue = getCurrentFilterInputValue.call(this);
4057
4386
 
4058
4387
  if (filterValue === this[lastFilterValueSymbol]) {
@@ -4339,6 +4668,24 @@ function handleOptionKeyboardEvents(event) {
4339
4668
  }
4340
4669
  }
4341
4670
 
4671
+ /**
4672
+ * @private
4673
+ * @param {KeyboardEvent} event
4674
+ */
4675
+ function handleCreateOptionKeyboardEvents(event) {
4676
+ switch (event?.code) {
4677
+ case "Enter":
4678
+ case "Space":
4679
+ createFilteredOption.call(this).catch((error) => {
4680
+ addErrorAttribute(this, error);
4681
+ });
4682
+ event.preventDefault();
4683
+ break;
4684
+ default:
4685
+ handleOptionKeyboardEvents.call(this, event);
4686
+ }
4687
+ }
4688
+
4342
4689
  /**
4343
4690
  * @private
4344
4691
  * @return {string}
@@ -5235,8 +5582,7 @@ function show() {
5235
5582
 
5236
5583
  delete this[lazyLoadRequestSymbol];
5237
5584
  this[lazyLoadDoneSymbol] = false;
5238
- this[lazyLoadErrorSymbol] =
5239
- e instanceof Error ? e.message : String(e);
5585
+ this[lazyLoadErrorSymbol] = e instanceof Error ? e.message : String(e);
5240
5586
  addErrorAttribute(this, e);
5241
5587
  setStatusOrRemoveBadges.call(this, "error");
5242
5588
  });
@@ -5664,6 +6010,9 @@ function initEventHandler() {
5664
6010
  case "filter":
5665
6011
  handleFilterKeyboardEvents.call(self, event);
5666
6012
  break;
6013
+ case "create-option":
6014
+ handleCreateOptionKeyboardEvents.call(self, event);
6015
+ break;
5667
6016
  case "option-label":
5668
6017
  case "option-control":
5669
6018
  case "option":
@@ -5755,6 +6104,8 @@ function initEventHandler() {
5755
6104
  self.addEventListener("keydown", self[keyEventHandler]);
5756
6105
 
5757
6106
  const callback = () => {
6107
+ ensureCreateOptionElement.call(self);
6108
+ updateCreateOptionAction.call(self);
5758
6109
  if (this[debounceOptionsMutationObserverSymbol] instanceof DeadMansSwitch) {
5759
6110
  try {
5760
6111
  this[debounceOptionsMutationObserverSymbol].touch();
@@ -5962,6 +6313,8 @@ function initControlReferences() {
5962
6313
  this[optionsElementSymbol] = this.shadowRoot.querySelector(
5963
6314
  `[${ATTRIBUTE_ROLE}=options]`,
5964
6315
  );
6316
+ ensureCreateOptionElement.call(this);
6317
+ updateCreateOptionAction.call(this);
5965
6318
  this[noOptionsAvailableElementSymbol] = this.shadowRoot.querySelector(
5966
6319
  `[${ATTRIBUTE_ROLE}="no-options"]`,
5967
6320
  );
@@ -6054,6 +6407,13 @@ function getTemplate() {
6054
6407
  <div part="content" class="flex" data-monster-replace="path:content">
6055
6408
  <div part="options" data-monster-role="options" data-monster-insert="options path:options"
6056
6409
  tabindex="-1"></div>
6410
+ <div data-monster-role="create-option" role="option" aria-selected="false"
6411
+ data-monster-visibility="hidden" tabindex="-1">
6412
+ <button type="button" part="create-option" tabindex="-1">
6413
+ <span aria-hidden="true" data-monster-role="create-option-icon">+</span>
6414
+ <span data-monster-role="create-option-label"></span>
6415
+ </button>
6416
+ </div>
6057
6417
  </div>
6058
6418
  <div part="pagination-footer" data-monster-role="pagination-footer">
6059
6419
  <div part="remote-info"
@@ -100,6 +100,10 @@ div[data-monster-role=popper] {
100
100
  z-index: var(--monster-z-index-popover);
101
101
  }
102
102
 
103
+ div[data-monster-role=popper] > [part=content] {
104
+ flex-direction: column;
105
+ }
106
+
103
107
  @media (prefers-color-scheme: light) {
104
108
  [data-monster-role=control] {
105
109
  background-color: var(--monster-bg-color-primary-1);
@@ -357,9 +361,44 @@ div[data-monster-role=selection] {
357
361
  outline-offset: -2px;
358
362
  }
359
363
 
364
+ [data-monster-role=create-option] {
365
+ align-items: center;
366
+ box-sizing: border-box;
367
+ cursor: pointer;
368
+ display: flex;
369
+ min-block-size: 2.75rem;
370
+ padding: var(--monster-select-option-padding, 2px 6px);
371
+ width: 100%;
372
+ }
373
+
374
+ [data-monster-role=create-option][data-monster-visibility=hidden] {
375
+ display: none;
376
+ }
377
+
378
+ [data-monster-role=create-option] > button {
379
+ appearance: none;
380
+ border: 0;
381
+ cursor: inherit;
382
+ display: inline-flex;
383
+ font-family: inherit;
384
+ gap: 0.4em;
385
+ pointer-events: none;
386
+ }
387
+
388
+ [data-monster-role=create-option][data-monster-focused=true] {
389
+ outline: 1px dashed var(--monster-color-selection-2);
390
+ outline-offset: -2px;
391
+ }
392
+
393
+ [data-monster-role=create-option-icon] {
394
+ font-size: 1.15em;
395
+ line-height: 0.75;
396
+ }
397
+
360
398
  @media (prefers-color-scheme: dark) {
361
399
 
362
- [data-monster-role=option][data-monster-focused=true] {
400
+ [data-monster-role=option][data-monster-focused=true],
401
+ [data-monster-role=create-option][data-monster-focused=true] {
363
402
  outline: 1px dashed var(--monster-color-selection-4);
364
403
  }
365
404
  }