diff --git a/.changeset/autocomplete-external-mode.md b/.changeset/autocomplete-external-mode.md
new file mode 100644
index 00000000..e5da6a61
--- /dev/null
+++ b/.changeset/autocomplete-external-mode.md
@@ -0,0 +1,12 @@
+---
+"@cscfi/csc-ui": minor
+"@cscfi/csc-ui-react": minor
+---
+
+c-autocomplete gains an external (async) data mode. A new `external` prop
+turns internal filtering off so `items` can come from a server, a new
+`change:query` event carries the typed query (it also fires with an empty
+string whenever the panel opens — use that to load the initial list), and
+the panel shows a loading row while `loading` is set with nothing to
+display. The selected label now survives `items` swaps. Default filtering
+behaviour is unchanged.
diff --git a/CLAUDE.md b/CLAUDE.md
index 8fcd89f6..ae370b99 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -23,7 +23,7 @@ A **pnpm workspaces monorepo** (ADR-0001, no Lerna):
```bash
# From the root
pnpm build # build all packages (topological)
-pnpm dev # watch csc-ui + docs dev server (http://localhost:3000)
+pnpm dev # watch csc-ui + docs dev server (http://localhost:3500)
pnpm ui
diff --git a/packages/csc-ui-documentation/app/examples/c-autocomplete/external.angular.ts b/packages/csc-ui-documentation/app/examples/c-autocomplete/external.angular.ts
new file mode 100644
index 00000000..1f84798d
--- /dev/null
+++ b/packages/csc-ui-documentation/app/examples/c-autocomplete/external.angular.ts
@@ -0,0 +1,112 @@
+// @ts-nocheck — documentation code sample; shown as text, never compiled here
+import {
+ AfterViewInit,
+ Component,
+ CUSTOM_ELEMENTS_SCHEMA,
+ ElementRef,
+ OnDestroy,
+ signal,
+ viewChild,
+} from '@angular/core';
+import type { CAutocompleteElement, CAutocompleteItem } from '@cscfi/csc-ui';
+
+@Component({
+ selector: 'app-example',
+ standalone: true,
+ schemas: [CUSTOM_ELEMENTS_SCHEMA],
+ template: `
+
+ {/* With `external`, the autocomplete renders `items` verbatim and only
+ emits `change:query`; filtering happens in a simulated server
+ request. The event also fires with an empty string when the panel
+ opens, which is what loads the initial unfiltered list. The
+ component ships no debounce — do it in the handler, as here. */}
+ setCountry(event.detail as string | null)}
+ onChangeQuery={onQuery}
+ />
+
+
@@ -225,7 +236,13 @@ export interface CAutocompleteProps {
* @freeform
*/
errorMessage?: string;
- /** Custom filter predicate; receives a normalized option + the query */
+ /**
+ * The consumer owns filtering: the component renders its options verbatim
+ * and only emits `change:query` as the user types. Pair with `loading` and
+ * an async data source feeding `items`
+ */
+ external?: boolean;
+ /** Custom filter predicate; receives a normalized option + the query. Ignored when `external` is set */
filter?: CAutocompleteFilter;
/** Hide the hint and error messages */
hideDetails?: boolean;
@@ -296,7 +313,7 @@ export interface CAutocompleteProps {
* @csspart card - The elevated surface inside the panel holding the search row and the list
* @csspart search - The search-input row at the top of the panel
* @csspart list - The scrollable options listbox
- * @csspart info - The no-results row shown when the query matches no options
+ * @csspart info - The info row: loading while `loading` with an empty list, otherwise no-results when the query matches no options
*
* @subcomponents c-option, c-option-value
*/
@@ -326,6 +343,13 @@ interface CAutocompleteEvents {
* form-style listeners.
*/
change: void;
+ /**
+ * Fired whenever the query changes — on every keystroke in the search
+ * input, and with an empty string when the panel opens. Carries the query
+ * string. With `external`, drive your data source from this (debounce on
+ * your side) and feed the results back via `items`.
+ */
+ 'change:query': string;
/**
* Fired when the selected value changes (an option is committed or the
* selection is cleared), carrying the new value — the option's value, or
@@ -352,7 +376,9 @@ interface CAutocompleteEvents {
* (Popover API + CSS anchor positioning, the c-menu mechanism) holds a
* dedicated SEARCH INPUT above the options. v-model binds the selected value
* (scalar, or {name,value} with return-object); the query is internal,
- * client-side state filtered through the `filter` predicate.
+ * client-side state filtered through the `filter` predicate — or, with
+ * `external` (ADR-0029), forwarded to the consumer via `change:query` while
+ * the options render verbatim.
*
* a11y: an editable combobox — DOM focus stays in the search input while open;
* options are highlighted virtually via `aria-activedescendant` (never real
@@ -412,6 +438,7 @@ const props = withDefaults(defineProps(), {
clearable: false,
disabled: false,
errorMessage: '',
+ external: false,
filter: undefined,
hideDetails: false,
hint: '',
@@ -460,6 +487,15 @@ watch(
const query = ref('');
+// Single write-path for user-driven query changes so the emission can never
+// drift from the state; the panel-open reset emits separately (always).
+const setQuery = (next: string) => {
+ if (query.value === next) return;
+
+ query.value = next;
+ emit('change:query', next);
+};
+
const isOpen = ref(false);
const activeIndex = ref(-1);
@@ -555,10 +591,14 @@ const filterFn = computed(
((option, q) => option.label.toLowerCase().startsWith(q.toLowerCase())),
);
+// With `external` the consumer owns filtering (c-data-table's contract): the
+// options render verbatim and `change:query` is the only filtering signal.
+const externalOn = computed(() => coerceBoolean(props.external));
+
const filteredOptions = computed(() => {
const q = query.value;
- if (!q) return normalizedOptions.value;
+ if (externalOn.value || !q) return normalizedOptions.value;
const fn = filterFn.value;
@@ -578,13 +618,33 @@ const selectedValue = computed(() => {
const isSelected = (opt: NormalizedOption) =>
selectedValue.value != null && opt.value === selectedValue.value;
+// Label of the last committed option, keyed on its value: with `external`
+// the current option list may no longer contain the selection, so the closed
+// field's label must survive `items` swaps.
+const committedLabel = ref<{ label: string; value: number | string } | null>(
+ null,
+);
+
const displayLabel = computed(() => {
- if (selectedValue.value == null) return '';
+ const sel = selectedValue.value;
- return (
- normalizedOptions.value.find((o) => o.value === selectedValue.value)
- ?.label ?? ''
- );
+ if (sel == null) return '';
+
+ const fromOptions = normalizedOptions.value.find(
+ (o) => o.value === sel,
+ )?.label;
+
+ if (fromOptions != null) return fromOptions;
+
+ if (committedLabel.value?.value === sel) return committedLabel.value.label;
+
+ // Programmatically-set values the options can't resolve: an object value
+ // carries its own label; a scalar renders as-is.
+ const v = value.value;
+
+ if (v && typeof v === 'object') return (v as CAutocompleteItem).name;
+
+ return String(sel);
});
// ---- value plumbing -----------------------------------------------------
@@ -595,6 +655,7 @@ const commit = (opt: NormalizedOption) => {
: opt.value;
value.value = next;
+ committedLabel.value = { label: opt.label, value: opt.value };
emitModelValue(host, next);
emit('change', undefined, { bubbles: true, composed: true });
@@ -616,7 +677,8 @@ const onSelect = (opt: NormalizedOption) => {
const onReset = (event?: Event) => {
event?.stopPropagation();
value.value = null;
- query.value = '';
+ committedLabel.value = null;
+ setQuery('');
emitModelValue(host, null);
emit('change', undefined, { bubbles: true, composed: true });
@@ -669,7 +731,11 @@ const onToggle = (event: Event) => {
if (nowOpen) {
void ensureAnchorPositioning(host?.shadowRoot);
addDismissListeners();
+
+ // Reset the query and tell the consumer — always, even when it was
+ // already empty: with `external` this is what loads the default list.
query.value = '';
+ emit('change:query', '');
// Seed the active option from the current selection, else the first
// enabled option.
@@ -756,7 +822,7 @@ const onFieldKeyDown = (event: KeyboardEvent) => {
) {
openPanel();
requestAnimationFrame(() => {
- query.value = event.key;
+ setQuery(event.key);
if (searchRef.value) searchRef.value.value = event.key;
activeIndex.value = filteredOptions.value.findIndex((o) => !o.disabled);
@@ -784,7 +850,7 @@ const moveActive = (dir: -1 | 1) => {
};
const onSearchInput = (event: Event) => {
- query.value = (event.target as HTMLInputElement).value;
+ setQuery((event.target as HTMLInputElement).value);
// Re-seed the active option to the first match so Enter selects something
// sensible and the aria-activedescendant stays valid.
requestAnimationFrame(() => {
@@ -873,13 +939,34 @@ const updateStatusText = () => {
statusDebounce = window.setTimeout(() => {
const n = filteredOptions.value.length;
- statusText.value = n
- ? `${n} result${n !== 1 ? 's' : ''} available, navigate using the up and down arrows`
- : 'No search results available';
+ statusText.value =
+ props.loading && !n
+ ? 'Loading results'
+ : n
+ ? `${n} result${n !== 1 ? 's' : ''} available, navigate using the up and down arrows`
+ : 'No search results available';
statusDebounce = null;
}, 1400);
};
+// With `external`, options arrive asynchronously after the query event: keep
+// the virtual highlight (`aria-activedescendant`) pointing at a live enabled
+// row and re-announce the count when the fresh list lands.
+watch([filteredOptions, () => props.loading], () => {
+ if (!isOpen.value) return;
+
+ const opts = filteredOptions.value;
+
+ const active = opts[activeIndex.value];
+
+ if (!active || active.disabled) {
+ activeIndex.value = opts.findIndex((o) => !o.disabled);
+ scrollActiveIntoView();
+ }
+
+ updateStatusText();
+});
+
// ---- light-dismiss ------------------------------------------------------
const onDocPointerDown = (event: Event) => {
diff --git a/packages/csc-ui/src/components/c-autocomplete/usage.md b/packages/csc-ui/src/components/c-autocomplete/usage.md
index 321ae2e5..0233710f 100644
--- a/packages/csc-ui/src/components/c-autocomplete/usage.md
+++ b/packages/csc-ui/src/components/c-autocomplete/usage.md
@@ -1,2 +1,28 @@
-A filterable value-selection component: a readonly value field that opens a
-popover panel with a search input above the matching options.
+A filterable value-selection component: a readonly value field that opens a popover panel with a search input above the matching options.
+
+## Filtering
+
+By default the component filters its options itself: the query typed into the
+search input is matched against the start of each option's label. Supply a
+`filter` predicate to change the matching — it receives the normalized option
+and the query, and keeps the option when it returns `true` (see the
+custom-filter example). Because `filter` is a function, it must be bound as a
+DOM property, not an attribute.
+
+## External data
+
+Set `external` to hand filtering to your own code — for example a server
+search endpoint. The component then renders `items` verbatim and emits a
+`change:query` event carrying the query string: on every keystroke, and with
+an empty string when the panel opens (use that to load the initial,
+unfiltered list). Set `loading` while a request is in flight; the panel shows
+a loading row when there is nothing to display yet and keeps the current
+options on screen during a refresh.
+
+The component ships no debounce and no minimum query length — debounce the
+requests in your handler and skip fetches for too-short queries yourself (see
+the external example). The closed field keeps showing the selected option's
+label even when a later fetch no longer includes it: the label is remembered
+when the option is committed, and a programmatically set value resolves its
+label from the current options, or from the object's `name` when
+`return-object` is used.
diff --git a/packages/csc-ui/src/components/c-dropdown/CDropdown.vue b/packages/csc-ui/src/components/c-dropdown/CDropdown.vue
index 0f7e6a59..0d02d0e2 100644
--- a/packages/csc-ui/src/components/c-dropdown/CDropdown.vue
+++ b/packages/csc-ui/src/components/c-dropdown/CDropdown.vue
@@ -37,24 +37,9 @@
role="listbox"
tabindex="-1"
>
-
-
-
- {{ minimumQueryItem }}
-
-
-
-
- {{ emptyItem }}
-
-
-
+
@@ -101,7 +86,7 @@
-
+ {{ item.name }}
@@ -119,7 +104,7 @@
* @slot input-top - Target the c-input is moved into when the menu opens below the field
* @slot input-bottom - Target the c-input is moved into when the menu opens above the field
*/
-import { mdiAlert, mdiCheck, mdiInformation } from '@mdi/js';
+import { mdiCheck } from '@mdi/js';
import { tv } from 'tailwind-variants';
import {
computed,
@@ -149,8 +134,8 @@ interface CDropdownEvents {
/**
* Styling lives in this `tailwind-variants` config: the slots are
- * the menu's visual regions (`dialog`, `list`, `item`, the info/empty row and
- * its icon, the selected-row check). `variants.disabled` replaces the
+ * the menu's visual regions (`dialog`, `list`, `item`, the selected-row
+ * check). `variants.disabled` replaces the
* `li.disabled` cascade. The per-component `--c-dropdown-*` override-variable
* layer is dropped in favour of the semantic design tokens (the overlay
* surface and the primary item-state roles); customization is via
@@ -171,8 +156,6 @@ const dropdown = tv({
// top/left/width/maxHeight the JS writes inline drive placement.
dialog:
'rounded border-0 bg-transparent m-0 mt-[-4px] p-0 pt-1 overflow-visible fixed',
- info: 'flex items-center flex-nowrap gap-2 text-sm min-h-[42px] px-[10px] w-full cursor-default pointer-events-none whitespace-nowrap text-on-surface-muted',
- infoIcon: 'w-[18px] h-[18px] shrink-0 fill-current',
item: 'flex items-center flex-nowrap gap-3 cursor-pointer text-sm min-h-[42px] outline-none px-[10px] pointer-events-auto whitespace-nowrap w-full rounded select-none hover:bg-primary-subtle hover:text-primary hover:ring-1 hover:ring-inset hover:ring-primary focus:bg-primary-subtle focus:text-primary focus:ring-1 focus:ring-inset focus:ring-primary aria-selected:bg-primary-subtle aria-selected:text-primary aria-selected:rounded-none hover:aria-selected:rounded focus:aria-selected:rounded',
// Static list look; visibility + fade-in (`.active`) and the mobile
// full-screen layout stay in the escape-hatch