From b75ff3b47f8bd3abb6075ecbc2d891ec5942c756 Mon Sep 17 00:00:00 2001 From: Ville Eriksson Date: Thu, 13 Aug 2026 06:34:22 +0000 Subject: [PATCH 1/2] Chore(docs): Run the docs dev server on port 3500 Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 2 +- packages/csc-ui-documentation/nuxt.config.ts | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) 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: ` +
+ + + +

Value: {{ country() ?? 'null' }}

+
+ `, +}) +export class ExternalExampleComponent implements AfterViewInit, OnDestroy { + autocomplete = + viewChild.required>('autocomplete'); + + // ---- a pretend server ------------------------------------------------ + ALL: CAutocompleteItem[] = [ + { name: 'Austria', value: 'at' }, + { name: 'Denmark', value: 'dk' }, + { name: 'Estonia', value: 'ee' }, + { name: 'Finland', value: 'fi' }, + { name: 'France', value: 'fr' }, + { name: 'Germany', value: 'de' }, + { name: 'Iceland', value: 'is' }, + { name: 'Netherlands', value: 'nl' }, + { name: 'Norway', value: 'no' }, + { name: 'Sweden', value: 'se' }, + ]; + + search = (query: string): Promise => + new Promise((resolve) => + setTimeout( + () => + resolve( + this.ALL.filter((item) => + item.name.toLowerCase().includes(query.toLowerCase()), + ), + ), + 600, + ), + ); + // ----------------------------------------------------------------------- + + country = signal(null); + + items = signal([]); + + loading = signal(false); + + debounce?: ReturnType; + + // Drop responses a newer query has superseded. + requestId = 0; + + async load(query: string) { + const id = ++this.requestId; + this.loading.set(true); + + const result = await this.search(query); + + if (id !== this.requestId) return; + this.items.set(result); + this.loading.set(false); + } + + // Colon-named events ("change:query") cannot be bound in an Angular + // template, so listen on the element directly. The component ships no + // debounce — do it in the handler, as here. + ngAfterViewInit() { + this.autocomplete().nativeElement.addEventListener( + 'change:query', + (event) => { + clearTimeout(this.debounce); + + const query = (event as CustomEvent).detail; + this.debounce = setTimeout(() => this.load(query), 300); + }, + ); + } + + ngOnDestroy() { + clearTimeout(this.debounce); + } +} diff --git a/packages/csc-ui-documentation/app/examples/c-autocomplete/external.react.tsx b/packages/csc-ui-documentation/app/examples/c-autocomplete/external.react.tsx new file mode 100644 index 00000000..6f27d32e --- /dev/null +++ b/packages/csc-ui-documentation/app/examples/c-autocomplete/external.react.tsx @@ -0,0 +1,87 @@ +// @ts-nocheck — documentation code sample; shown as text, never compiled here +import { useEffect, useRef, useState } from 'react'; +import { CAutocomplete } from '@cscfi/csc-ui-react'; +import type { CAutocompleteItem } from '@cscfi/csc-ui'; + +// ---- a pretend server ------------------------------------------------ +const ALL: CAutocompleteItem[] = [ + { name: 'Austria', value: 'at' }, + { name: 'Denmark', value: 'dk' }, + { name: 'Estonia', value: 'ee' }, + { name: 'Finland', value: 'fi' }, + { name: 'France', value: 'fr' }, + { name: 'Germany', value: 'de' }, + { name: 'Iceland', value: 'is' }, + { name: 'Netherlands', value: 'nl' }, + { name: 'Norway', value: 'no' }, + { name: 'Sweden', value: 'se' }, +]; + +const search = (query: string): Promise => + new Promise((resolve) => + setTimeout( + () => + resolve( + ALL.filter((item) => + item.name.toLowerCase().includes(query.toLowerCase()), + ), + ), + 600, + ), + ); +// ----------------------------------------------------------------------- + +export const External = () => { + const [country, setCountry] = useState(null); + + const [items, setItems] = useState([]); + + const [loading, setLoading] = useState(false); + + const debounce = useRef>(); + + // Drop responses a newer query has superseded. + const requestId = useRef(0); + + const load = async (query: string) => { + const id = ++requestId.current; + setLoading(true); + + const result = await search(query); + + if (id !== requestId.current) return; + setItems(result); + setLoading(false); + }; + + const onQuery = (event: CustomEvent) => { + clearTimeout(debounce.current); + debounce.current = setTimeout(() => load(event.detail), 300); + }; + + useEffect(() => () => clearTimeout(debounce.current), []); + + return ( +
+ {/* 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} + /> + +

Value: {country ?? 'null'}

+
+ ); +}; diff --git a/packages/csc-ui-documentation/app/examples/c-autocomplete/external.typescript.html b/packages/csc-ui-documentation/app/examples/c-autocomplete/external.typescript.html new file mode 100644 index 00000000..8c34acb6 --- /dev/null +++ b/packages/csc-ui-documentation/app/examples/c-autocomplete/external.typescript.html @@ -0,0 +1,9 @@ + + +

Value: null

diff --git a/packages/csc-ui-documentation/app/examples/c-autocomplete/external.typescript.ts b/packages/csc-ui-documentation/app/examples/c-autocomplete/external.typescript.ts new file mode 100644 index 00000000..cce04355 --- /dev/null +++ b/packages/csc-ui-documentation/app/examples/c-autocomplete/external.typescript.ts @@ -0,0 +1,63 @@ +import type { CAutocompleteItem } from '@cscfi/csc-ui'; + +// ---- a pretend server ------------------------------------------------ +const ALL: CAutocompleteItem[] = [ + { name: 'Austria', value: 'at' }, + { name: 'Denmark', value: 'dk' }, + { name: 'Estonia', value: 'ee' }, + { name: 'Finland', value: 'fi' }, + { name: 'France', value: 'fr' }, + { name: 'Germany', value: 'de' }, + { name: 'Iceland', value: 'is' }, + { name: 'Netherlands', value: 'nl' }, + { name: 'Norway', value: 'no' }, + { name: 'Sweden', value: 'se' }, +]; + +const search = (query: string): Promise => + new Promise((resolve) => + setTimeout( + () => + resolve( + ALL.filter((item) => + item.name.toLowerCase().includes(query.toLowerCase()), + ), + ), + 600, + ), + ); +// ----------------------------------------------------------------------- + +// 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. +const autocomplete = document.querySelector('c-autocomplete')!; + +let debounce: ReturnType | undefined; + +// Drop responses a newer query has superseded. +let requestId = 0; + +const load = async (query: string) => { + const id = ++requestId; + autocomplete.loading = true; + + const result = await search(query); + + if (id !== requestId) return; + autocomplete.items = result; + autocomplete.loading = false; +}; + +// The component ships no debounce — do it in the handler, as here. +autocomplete.addEventListener('change:query', (event) => { + clearTimeout(debounce); + + const query = event.detail; + debounce = setTimeout(() => load(query), 300); +}); + +autocomplete.addEventListener('changeValue', (event) => { + document.querySelector('p')!.textContent = `Value: ${event.detail ?? 'null'}`; +}); diff --git a/packages/csc-ui-documentation/app/examples/c-autocomplete/external.vue b/packages/csc-ui-documentation/app/examples/c-autocomplete/external.vue new file mode 100644 index 00000000..8cd1c34c --- /dev/null +++ b/packages/csc-ui-documentation/app/examples/c-autocomplete/external.vue @@ -0,0 +1,87 @@ + + + diff --git a/packages/csc-ui-react/src/components.ts b/packages/csc-ui-react/src/components.ts index b22af117..ab0b4600 100644 --- a/packages/csc-ui-react/src/components.ts +++ b/packages/csc-ui-react/src/components.ts @@ -153,6 +153,7 @@ export const CAutocomplete = createComponent({ elementClass: elementClass('c-autocomplete'), events: { onChange: 'change' as EventName, + onChangeQuery: 'change:query' as EventName, onChangeValue: 'changeValue' as EventName, onInput: 'input' as EventName, onUpdateValue: 'update:value' as EventName, diff --git a/packages/csc-ui/src/components/c-autocomplete/CAutocomplete.vue b/packages/csc-ui/src/components/c-autocomplete/CAutocomplete.vue index 5a4acf76..bd144bfd 100644 --- a/packages/csc-ui/src/components/c-autocomplete/CAutocomplete.vue +++ b/packages/csc-ui/src/components/c-autocomplete/CAutocomplete.vue @@ -136,9 +136,20 @@ role="listbox" tabindex="-1" > + +
  • + + Loading +
  • +
  • @@ -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 }} -
  • - - @@ -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