diff --git a/packages/react-library/lib/components/stencil-generated/index.ts b/packages/react-library/lib/components/stencil-generated/index.ts index 54cd6b13a..4c7520395 100644 --- a/packages/react-library/lib/components/stencil-generated/index.ts +++ b/packages/react-library/lib/components/stencil-generated/index.ts @@ -7,6 +7,7 @@ import type { JSX } from '@dnncommunity/dnn-elements'; +export const DnnAutocomplete = /*@__PURE__*/createReactComponent('dnn-autocomplete'); export const DnnButton = /*@__PURE__*/createReactComponent('dnn-button'); export const DnnCheckbox = /*@__PURE__*/createReactComponent('dnn-checkbox'); export const DnnChevron = /*@__PURE__*/createReactComponent('dnn-chevron'); diff --git a/packages/stencil-library/custom-elements.json b/packages/stencil-library/custom-elements.json index 9403d976d..f2057020e 100644 --- a/packages/stencil-library/custom-elements.json +++ b/packages/stencil-library/custom-elements.json @@ -1,6 +1,179 @@ { "version": "1.0.0", "modules": [ + { + "kind": "javascript-module", + "path": "src/components/dnn-autocomplete/dnn-autocomplete.tsx", + "declarations": [ + { + "kind": "class", + "name": "dnn-autocomplete.tsx", + "tagName": "dnn-autocomplete", + "description": "Building a component that is flexible enough for multiple use cases is not easy. This component externalizes some of its behavior to make it more reusable. To use it effectivelly please read the usage examples carefuly.", + "attributes": [ + { + "name": "disabled", + "type": { + "text": "boolean" + }, + "description": "Defines whether the field is disabled.", + "required": false + }, + { + "name": "help-text", + "type": { + "text": "string" + }, + "description": "Defines the help label displayed under the field.", + "required": false + }, + { + "name": "label", + "type": { + "text": "string" + }, + "description": "The label for this autocomplete.", + "required": false + }, + { + "name": "name", + "type": { + "text": "string" + }, + "description": "The name for this autocomplete when used in forms.", + "required": false + }, + { + "name": "preload-threshold-pixels", + "type": { + "text": "number" + }, + "description": "How many suggestions to preload in pixels of their height.\nThis is used to calculate the virtual scroll height and request\nmore items before they get into view.", + "default": "1000", + "required": false + }, + { + "name": "required", + "type": { + "text": "boolean" + }, + "description": "Defines whether the field requires having a value.", + "required": false + }, + { + "name": "total-suggestions", + "type": { + "text": "number" + }, + "description": "The total amount of suggestions for the given search query.\nThis can be used to show virtual scroll and pagination progressive feeding.\nThe needMoreItems event should be used to request more items.", + "required": false + }, + { + "name": "value", + "type": { + "text": "string" + }, + "description": "Defines the value for this autocomplete", + "required": false + } + ], + "members": [ + { + "kind": "field", + "name": "renderSuggestion", + "type": { + "text": "(suggestion: DnnAutocompleteSuggestion) => HTMLElement" + }, + "description": "Callback to render suggestions, if not provided, only the label will be rendered.", + "required": false + }, + { + "kind": "field", + "name": "suggestions", + "type": { + "text": "DnnAutocompleteSuggestion[]" + }, + "description": "Sets the list of suggestions.", + "default": "[]", + "required": false + }, + { + "kind": "method", + "name": "checkValidity", + "description": "Reports the input validity details. See https://developer.mozilla.org/en-US/docs/Web/API/ValidityState", + "signature": "checkValidity() => Promise" + }, + { + "kind": "method", + "name": "setCustomValidity", + "description": "Can be used to set a custom validity message.", + "signature": "setCustomValidity(message: string) => Promise" + } + ], + "events": [ + { + "name": "itemSelected", + "type": { + "text": "string" + }, + "description": "Fires when an item is selected." + }, + { + "name": "needMoreItems", + "type": { + "text": "NeedMoreItemsEventArgs" + }, + "description": "Fires when the component needs to display more items in the suggestions." + }, + { + "name": "searchQueryChanged", + "type": { + "text": "string" + }, + "description": "Fires when the search query has changed.\nThis is almost like valueInput, but it is debounced\nand can be used to trigger a search query without overloading\nAPI endpoints while typing." + }, + { + "name": "valueChange", + "type": { + "text": "number | string | string[]" + }, + "description": "Fires when the value has changed and the user exits the input." + }, + { + "name": "valueInput", + "type": { + "text": "number | string | string[]" + }, + "description": "Fires when the using is inputing data (on keystrokes)." + } + ], + "slots": [], + "cssProperties": [ + { + "name": "--background-color", + "description": "Defines the background color." + }, + { + "name": "--control-radius", + "description": "Defines the radius for the control corners." + }, + { + "name": "--danger-color", + "description": "Defines the danger color used for invalid data." + }, + { + "name": "--focus-color", + "description": "Defines the color when the component is focused." + }, + { + "name": "--foreground-color", + "description": "Defines the foreground color." + } + ], + "cssParts": [] + } + ] + }, { "kind": "javascript-module", "path": "src/components/dnn-button/dnn-button.tsx", @@ -61,7 +234,7 @@ "type": { "text": "\"button\" | \"reset\" | \"submit\"" }, - "description": "Optional button type,\r\ncan be either submit, reset or button and defaults to button if not specified.\r\nWarning: DNN wraps the whole page in a form, only use this if you are handling\r\nform submission manually.", + "description": "Optional button type,\ncan be either submit, reset or button and defaults to button if not specified.\nWarning: DNN wraps the whole page in a form, only use this if you are handling\nform submission manually.", "default": "'button'", "required": false }, @@ -88,7 +261,7 @@ "type": { "text": "\"danger\" | \"primary\" | \"secondary\" | \"tertiary\"" }, - "description": "Optional button style,\r\ncan be either primary, secondary or tertiary or danger and defaults to primary if not specified", + "description": "Optional button style,\ncan be either primary, secondary or tertiary or danger and defaults to primary if not specified", "default": "'primary'", "required": false } @@ -447,7 +620,7 @@ "text": "{ contrast: string; preview: string; cancel: string; confirm: string; normal: string; light: string; dark: string; }" }, "description": "Can be used to customize the text language.", - "default": "{\r\n contrast: \"Contrast\",\r\n preview: \"Preview\",\r\n cancel: \"Cancel\",\r\n confirm: \"Confirm\",\r\n normal: \"Normal\",\r\n light: \"Light\",\r\n dark: \"Dark\",\r\n }", + "default": "{\n contrast: \"Contrast\",\n preview: \"Preview\",\n cancel: \"Cancel\",\n confirm: \"Confirm\",\n normal: \"Normal\",\n light: \"Light\",\n dark: \"Dark\",\n }", "required": false } ], @@ -563,7 +736,7 @@ "type": { "text": "boolean" }, - "description": "If true, will allow the user to take a snapshot\r\nusing the device camera. (only works over https).", + "description": "If true, will allow the user to take a snapshot\nusing the device camera. (only works over https).", "default": "false", "required": false }, @@ -572,7 +745,7 @@ "type": { "text": "number" }, - "description": "Specifies the jpeg quality for when the device\r\ncamera is used to generate a picture.\r\nNeeds to be a number between 0 and 1 and defaults to 0.8", + "description": "Specifies the jpeg quality for when the device\ncamera is used to generate a picture.\nNeeds to be a number between 0 and 1 and defaults to 0.8", "default": "0.8", "required": false }, @@ -600,7 +773,7 @@ "type": { "text": "string[]" }, - "description": "A list of allowed file extensions.\r\nIf not specified, any file is allowed.\r\nEx: [\"jpg\", \"jpeg\", \"gif\", \"png\"]", + "description": "A list of allowed file extensions.\nIf not specified, any file is allowed.\nEx: [\"jpg\", \"jpeg\", \"gif\", \"png\"]", "required": false }, { @@ -816,7 +989,7 @@ "kind": "class", "name": "dnn-image-cropper.tsx", "tagName": "dnn-image-cropper", - "description": "Allows cropping an image in-browser with the option to enforce a specific final size.\r\nAll computation happens in the browser and the final image is emmited\r\nin an event that has a data-url of the image.", + "description": "Allows cropping an image in-browser with the option to enforce a specific final size.\nAll computation happens in the browser and the final image is emmited\nin an event that has a data-url of the image.", "attributes": [ { "name": "height", @@ -868,7 +1041,7 @@ "type": { "text": "ImageCropperResx" }, - "description": "Can be used to customize controls text.\r\nSome values support tokens, see default values for examples.", + "description": "Can be used to customize controls text.\nSome values support tokens, see default values for examples.", "required": false }, { @@ -1151,7 +1324,7 @@ "type": { "text": "string" }, - "description": "Optionally pass the aria-label text for the close button.\r\nDefaults to \"Close modal\" if not provided.", + "description": "Optionally pass the aria-label text for the close button.\nDefaults to \"Close modal\" if not provided.", "default": "\"Close modal\"", "required": false }, @@ -1169,7 +1342,7 @@ "type": { "text": "boolean" }, - "description": "Optionally you can pass false to not show the close button.\r\nIf you decide to do so, you should either not also prevent dismissal by clicking the backdrop\r\nor provide your own dismissal logic in the modal content.", + "description": "Optionally you can pass false to not show the close button.\nIf you decide to do so, you should either not also prevent dismissal by clicking the backdrop\nor provide your own dismissal logic in the modal content.", "default": "true", "required": false }, @@ -1542,7 +1715,7 @@ "type": { "text": "string" }, - "description": "Fires up each time the search query changes.\r\nThe data passed is the new query." + "description": "Fires up each time the search query changes.\nThe data passed is the new query." } ], "slots": [], @@ -2131,7 +2304,7 @@ "kind": "class", "name": "dnn-vertical-splitview.tsx", "tagName": "dnn-vertical-splitview", - "description": "This allows splitting a UI into vertical adjustable panels, the splitter itself is not part of this component.\r\n- The content for the left part should be injected in the `left` slot.\r\n- The content for the right part should be injected in the `right` slot.\r\n- The content for the actual splitter should go in the default slot and other UI elements can be injected as long as you handle their behaviour, by default only the drag behavior is implemented in the component.", + "description": "This allows splitting a UI into vertical adjustable panels, the splitter itself is not part of this component.\n- The content for the left part should be injected in the `left` slot.\n- The content for the right part should be injected in the `right` slot.\n- The content for the actual splitter should go in the default slot and other UI elements can be injected as long as you handle their behaviour, by default only the drag behavior is implemented in the component.", "attributes": [ { "name": "split-width-percentage", diff --git a/packages/stencil-library/licenses.json b/packages/stencil-library/licenses.json index af32535f6..48f1aadbc 100644 --- a/packages/stencil-library/licenses.json +++ b/packages/stencil-library/licenses.json @@ -1,94 +1,15 @@ { - "@babel/code-frame@7.12.11": { - "licenses": "MIT", - "repository": "https://github.com/babel/babel", - "publisher": "Sebastian McKenzie", - "email": "sebmck@gmail.com", - "path": "node_modules\\@babel\\code-frame", - "licenseFile": "C:\\dev\\dnn-elements\\packages\\stencil-library\\node_modules\\@babel\\code-frame\\LICENSE" - }, "@dnncommunity/dnn-elements@0.23.3-alpha.7": { "licenses": "MIT", "repository": "https://github.com/dnncommunity/dnn-elements", "path": "", - "licenseFile": "C:\\dev\\dnn-elements\\packages\\stencil-library\\README.md" - }, - "@eslint/eslintrc@0.4.3": { - "licenses": "MIT", - "repository": "https://github.com/eslint/eslintrc", - "publisher": "Nicholas C. Zakas", - "path": "node_modules\\@eslint\\eslintrc", - "licenseFile": "C:\\dev\\dnn-elements\\packages\\stencil-library\\node_modules\\@eslint\\eslintrc\\LICENSE" - }, - "@humanwhocodes/config-array@0.5.0": { - "licenses": "Apache-2.0", - "repository": "https://github.com/humanwhocodes/config-array", - "publisher": "Nicholas C. Zakas", - "path": "node_modules\\@humanwhocodes\\config-array", - "licenseFile": "C:\\dev\\dnn-elements\\packages\\stencil-library\\node_modules\\@humanwhocodes\\config-array\\LICENSE" - }, - "@stencil/eslint-plugin@0.4.0": { - "licenses": "MIT", - "repository": "https://github.com/ionic-team/stencil-eslint", - "path": "node_modules\\@stencil\\eslint-plugin", - "licenseFile": "C:\\dev\\dnn-elements\\packages\\stencil-library\\node_modules\\@stencil\\eslint-plugin\\LICENSE.md" - }, - "eslint-utils@2.1.0": { - "licenses": "MIT", - "repository": "https://github.com/mysticatea/eslint-utils", - "publisher": "Toru Nagashima", - "path": "node_modules\\eslint\\node_modules\\eslint-utils", - "licenseFile": "C:\\dev\\dnn-elements\\packages\\stencil-library\\node_modules\\eslint\\node_modules\\eslint-utils\\LICENSE" - }, - "eslint-visitor-keys@1.3.0": { - "licenses": "Apache-2.0", - "repository": "https://github.com/eslint/eslint-visitor-keys", - "publisher": "Toru Nagashima", - "path": "node_modules\\espree\\node_modules\\eslint-visitor-keys", - "licenseFile": "C:\\dev\\dnn-elements\\packages\\stencil-library\\node_modules\\espree\\node_modules\\eslint-visitor-keys\\LICENSE" - }, - "eslint@7.32.0": { - "licenses": "MIT", - "repository": "https://github.com/eslint/eslint", - "publisher": "Nicholas C. Zakas", - "email": "nicholas+npm@nczconsulting.com", - "path": "node_modules\\eslint", - "licenseFile": "C:\\dev\\dnn-elements\\packages\\stencil-library\\node_modules\\eslint\\LICENSE" - }, - "espree@7.3.1": { - "licenses": "BSD-2-Clause", - "repository": "https://github.com/eslint/espree", - "publisher": "Nicholas C. Zakas", - "email": "nicholas+npm@nczconsulting.com", - "path": "node_modules\\espree", - "licenseFile": "C:\\dev\\dnn-elements\\packages\\stencil-library\\node_modules\\espree\\LICENSE" - }, - "ignore@4.0.6": { - "licenses": "MIT", - "repository": "https://github.com/kaelzhang/node-ignore", - "publisher": "kael", - "path": "node_modules\\ignore", - "licenseFile": "C:\\dev\\dnn-elements\\packages\\stencil-library\\node_modules\\ignore\\LICENSE-MIT" - }, - "tslib@1.14.1": { - "licenses": "0BSD", - "repository": "https://github.com/Microsoft/tslib", - "publisher": "Microsoft Corp.", - "path": "node_modules\\tslib", - "licenseFile": "C:\\dev\\dnn-elements\\packages\\stencil-library\\node_modules\\tslib\\LICENSE.txt" - }, - "tsutils@3.0.0": { - "licenses": "MIT", - "repository": "https://github.com/ajafff/tsutils", - "publisher": "Klaus Meinhardt", - "path": "node_modules\\tsutils", - "licenseFile": "C:\\dev\\dnn-elements\\packages\\stencil-library\\node_modules\\tsutils\\LICENSE" + "licenseFile": "D:\\dnn-elements\\dnn-elements\\packages\\stencil-library\\README.md" }, - "typescript@4.9.5": { + "typescript@5.2.2": { "licenses": "Apache-2.0", "repository": "https://github.com/Microsoft/TypeScript", "publisher": "Microsoft Corp.", "path": "node_modules\\typescript", - "licenseFile": "C:\\dev\\dnn-elements\\packages\\stencil-library\\node_modules\\typescript\\LICENSE.txt" + "licenseFile": "D:\\dnn-elements\\dnn-elements\\packages\\stencil-library\\node_modules\\typescript\\LICENSE.txt" } } diff --git a/packages/stencil-library/src/components.d.ts b/packages/stencil-library/src/components.d.ts index 20d715c14..8b632d899 100644 --- a/packages/stencil-library/src/components.d.ts +++ b/packages/stencil-library/src/components.d.ts @@ -5,6 +5,7 @@ * It contains typing information for all components that exist in this project. */ import { HTMLStencilElement, JSXBase } from "@stencil/core/internal"; +import { DnnAutocompleteSuggestion, NeedMoreItemsEventArgs } from "./components/dnn-autocomplete/types"; import { CheckedState } from "./components/dnn-checkbox/types"; import { DnnColorInfo } from "./components/dnn-color-input/dnn-color-info"; import { ColorInfo } from "./utilities/colorInfo"; @@ -17,6 +18,7 @@ import { ILocalization } from "./components/dnn-permissions-grid/localization-in import { ISearchedUser } from "./components/dnn-permissions-grid/searched-user-interface"; import { Config } from "jodit/types/config"; import { DnnToggleChangeEventDetail } from "./components/dnn-toggle/toggle-interface"; +export { DnnAutocompleteSuggestion, NeedMoreItemsEventArgs } from "./components/dnn-autocomplete/types"; export { CheckedState } from "./components/dnn-checkbox/types"; export { DnnColorInfo } from "./components/dnn-color-input/dnn-color-info"; export { ColorInfo } from "./utilities/colorInfo"; @@ -30,6 +32,56 @@ export { ISearchedUser } from "./components/dnn-permissions-grid/searched-user-i export { Config } from "jodit/types/config"; export { DnnToggleChangeEventDetail } from "./components/dnn-toggle/toggle-interface"; export namespace Components { + interface DnnAutocomplete { + /** + * Reports the input validity details. See https://developer.mozilla.org/en-US/docs/Web/API/ValidityState + */ + "checkValidity": () => Promise; + /** + * Defines whether the field is disabled. + */ + "disabled": boolean; + /** + * Defines the help label displayed under the field. + */ + "helpText": string; + /** + * The label for this autocomplete. + */ + "label": string; + /** + * The name for this autocomplete when used in forms. + */ + "name": string; + /** + * How many suggestions to preload in pixels of their height. This is used to calculate the virtual scroll height and request more items before they get into view. + */ + "preloadThresholdPixels": number; + /** + * Callback to render suggestions, if not provided, only the label will be rendered. + */ + "renderSuggestion": (suggestion: DnnAutocompleteSuggestion) => HTMLElement; + /** + * Defines whether the field requires having a value. + */ + "required": boolean; + /** + * Can be used to set a custom validity message. + */ + "setCustomValidity": (message: string) => Promise; + /** + * Sets the list of suggestions. + */ + "suggestions": DnnAutocompleteSuggestion[]; + /** + * The total amount of suggestions for the given search query. This can be used to show virtual scroll and pagination progressive feeding. The needMoreItems event should be used to request more items. + */ + "totalSuggestions": number; + /** + * Defines the value for this autocomplete + */ + "value": string; + } interface DnnButton { /** * Optionally add a confirmation dialog before firing the action. @@ -675,6 +727,10 @@ export namespace Components { "splitterWidth": number; } } +export interface DnnAutocompleteCustomEvent extends CustomEvent { + detail: T; + target: HTMLDnnAutocompleteElement; +} export interface DnnButtonCustomEvent extends CustomEvent { detail: T; target: HTMLDnnButtonElement; @@ -756,6 +812,27 @@ export interface DnnVerticalSplitviewCustomEvent extends CustomEvent { target: HTMLDnnVerticalSplitviewElement; } declare global { + interface HTMLDnnAutocompleteElementEventMap { + "valueChange": number | string | string[]; + "valueInput": number | string | string[]; + "needMoreItems": NeedMoreItemsEventArgs; + "searchQueryChanged": string; + "itemSelected": string; + } + interface HTMLDnnAutocompleteElement extends Components.DnnAutocomplete, HTMLStencilElement { + addEventListener(type: K, listener: (this: HTMLDnnAutocompleteElement, ev: DnnAutocompleteCustomEvent) => any, options?: boolean | AddEventListenerOptions): void; + addEventListener(type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void; + addEventListener(type: K, listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void; + addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void; + removeEventListener(type: K, listener: (this: HTMLDnnAutocompleteElement, ev: DnnAutocompleteCustomEvent) => any, options?: boolean | EventListenerOptions): void; + removeEventListener(type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | EventListenerOptions): void; + removeEventListener(type: K, listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | EventListenerOptions): void; + removeEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions): void; + } + var HTMLDnnAutocompleteElement: { + prototype: HTMLDnnAutocompleteElement; + new (): HTMLDnnAutocompleteElement; + }; interface HTMLDnnButtonElementEventMap { "confirmed": any; "canceled": any; @@ -1170,6 +1247,7 @@ declare global { new (): HTMLDnnVerticalSplitviewElement; }; interface HTMLElementTagNameMap { + "dnn-autocomplete": HTMLDnnAutocompleteElement; "dnn-button": HTMLDnnButtonElement; "dnn-checkbox": HTMLDnnCheckboxElement; "dnn-chevron": HTMLDnnChevronElement; @@ -1199,6 +1277,68 @@ declare global { } } declare namespace LocalJSX { + interface DnnAutocomplete { + /** + * Defines whether the field is disabled. + */ + "disabled"?: boolean; + /** + * Defines the help label displayed under the field. + */ + "helpText"?: string; + /** + * The label for this autocomplete. + */ + "label"?: string; + /** + * The name for this autocomplete when used in forms. + */ + "name"?: string; + /** + * Fires when an item is selected. + */ + "onItemSelected"?: (event: DnnAutocompleteCustomEvent) => void; + /** + * Fires when the component needs to display more items in the suggestions. + */ + "onNeedMoreItems"?: (event: DnnAutocompleteCustomEvent) => void; + /** + * Fires when the search query has changed. This is almost like valueInput, but it is debounced and can be used to trigger a search query without overloading API endpoints while typing. + */ + "onSearchQueryChanged"?: (event: DnnAutocompleteCustomEvent) => void; + /** + * Fires when the value has changed and the user exits the input. + */ + "onValueChange"?: (event: DnnAutocompleteCustomEvent) => void; + /** + * Fires when the using is inputing data (on keystrokes). + */ + "onValueInput"?: (event: DnnAutocompleteCustomEvent) => void; + /** + * How many suggestions to preload in pixels of their height. This is used to calculate the virtual scroll height and request more items before they get into view. + */ + "preloadThresholdPixels"?: number; + /** + * Callback to render suggestions, if not provided, only the label will be rendered. + */ + "renderSuggestion"?: (suggestion: DnnAutocompleteSuggestion) => HTMLElement; + /** + * Defines whether the field requires having a value. + */ + "required"?: boolean; + /** + * Sets the list of suggestions. + */ + "suggestions"?: DnnAutocompleteSuggestion[]; + /** + * The total amount of suggestions for the given search query. This can be used to show virtual scroll and pagination progressive feeding. The needMoreItems event should be used to request more items. + */ + "totalSuggestions"?: number; + /** + * Defines the value for this autocomplete + */ + "value"?: string; + } interface DnnButton { /** * Optionally add a confirmation dialog before firing the action. @@ -1875,6 +2015,7 @@ declare namespace LocalJSX { "splitterWidth"?: number; } interface IntrinsicElements { + "dnn-autocomplete": DnnAutocomplete; "dnn-button": DnnButton; "dnn-checkbox": DnnCheckbox; "dnn-chevron": DnnChevron; @@ -1907,6 +2048,7 @@ export { LocalJSX as JSX }; declare module "@stencil/core" { export namespace JSX { interface IntrinsicElements { + "dnn-autocomplete": LocalJSX.DnnAutocomplete & JSXBase.HTMLAttributes; "dnn-button": LocalJSX.DnnButton & JSXBase.HTMLAttributes; "dnn-checkbox": LocalJSX.DnnCheckbox & JSXBase.HTMLAttributes; "dnn-chevron": LocalJSX.DnnChevron & JSXBase.HTMLAttributes; diff --git a/packages/stencil-library/src/components/dnn-autocomplete/dnn-autocomplete.scss b/packages/stencil-library/src/components/dnn-autocomplete/dnn-autocomplete.scss new file mode 100644 index 000000000..7bb213405 --- /dev/null +++ b/packages/stencil-library/src/components/dnn-autocomplete/dnn-autocomplete.scss @@ -0,0 +1,102 @@ +:host { + display: inline-block; + + /** @prop --foreground-color: Defines the foreground color. */ + --foreground-color: var(--dnn-color-foreground, #000); + + /** @prop --background-color: Defines the background color. */ + --background-color: var(--dnn-color-background, #fff); + + /** @prop --focus-color: Defines the color when the component is focused. */ + --focus-color: var(--dnn-color-primary, #3792ED); + + /** @prop --danger-color: Defines the danger color used for invalid data. */ + --danger-color: var(--dnn-color-danger, #900); + + /** @prop --control-radius: Defines the radius for the control corners. */ + --control-radius: var(--dnn-controls-radius, 3px); +} + +dnn-fieldset{ + width: 100%; +} + +@keyframes shift { + 0% { + background-position: 0% 0; + } + 50% { + background-position: 100% 0; + } + 100% { + background-position: 200% 0; + } +} + +.inner-container{ + display: flex; + justify-content: space-between; + position: relative; + width: 100%; + + input { + border: none; + outline: none; + background-color: transparent; + color: var(--foreground-color); + text-align: var(--input-text-align); + width: 100%; + } + + svg.chevron-down{ + height: 1rem; + width: auto; + transform: scale(1.2); + cursor: pointer; + } + + ul{ + position: absolute; + border: 1px solid lightgray; + margin: 0; + padding: var(--dnn-controls-radius, 3px) 0; + overflow-y: auto; + width: 100%; + box-shadow: 2px 2px 6px 1px rgb(0 0 0 / 30%); + background-color: var(--dnn-color-background, white); + border-radius: var(--dnn-controls-radius, 3px); + z-index: 2; + display: none; + scroll-behavior: smooth; + &.show{ + display: block; + } + li { + display: block; + list-style-type: none; + cursor: pointer; + padding: 0 0.5rem; + &.selected { + background-color: lightgray; + } + &:hover { + background-color: lightgray; + } + } + .loading { + width: 100%; + height: 0.5rem; + border-radius: 0.5rem; + background: linear-gradient( + to right, + var(--background-color) 0%, + var(--foreground-color) 50%, + var(--background-color) 100%); + background-size: 200% 100%; + animation: shift 2s linear infinite; + width: 75%; + margin: 0 auto; + opacity: 0.5; + } + } +} diff --git a/packages/stencil-library/src/components/dnn-autocomplete/dnn-autocomplete.tsx b/packages/stencil-library/src/components/dnn-autocomplete/dnn-autocomplete.tsx new file mode 100644 index 000000000..a5d7e907c --- /dev/null +++ b/packages/stencil-library/src/components/dnn-autocomplete/dnn-autocomplete.tsx @@ -0,0 +1,371 @@ +import { Component, Prop, State, Event, Element, h, Host, EventEmitter, Method, AttachInternals, Listen } from '@stencil/core'; +import { DnnAutocompleteSuggestion, NeedMoreItemsEventArgs } from './types'; +import { Debounce } from '../../utilities/debounce'; + +@Component({ + tag: 'dnn-autocomplete', + styleUrl: 'dnn-autocomplete.scss', + shadow: true, + formAssociated: true, +}) +export class DnnAutocomplete { + + /** The label for this autocomplete. */ + @Prop() label: string; + + /** The name for this autocomplete when used in forms. */ + @Prop() name: string; + + /** Defines the help label displayed under the field. */ + @Prop() helpText: string; + + /** Defines the value for this autocomplete */ + @Prop({mutable: true, reflect: true}) value: string; + + /** Defines whether the field requires having a value. */ + @Prop() required: boolean; + + /** Defines whether the field is disabled. */ + @Prop() disabled: boolean; + + /** Sets the list of suggestions. */ + @Prop() suggestions: DnnAutocompleteSuggestion[] = []; + + /** Callback to render suggestions, if not provided, only the label will be rendered. */ + @Prop() renderSuggestion: (suggestion: DnnAutocompleteSuggestion) => HTMLElement; + + /** The total amount of suggestions for the given search query. + * This can be used to show virtual scroll and pagination progressive feeding. + * The needMoreItems event should be used to request more items. + */ + @Prop() totalSuggestions: number; + + /** How many suggestions to preload in pixels of their height. + * This is used to calculate the virtual scroll height and request + * more items before they get into view. + */ + @Prop() preloadThresholdPixels: number = 1000; + + @Element() element: HTMLDnnAutocompleteElement; + + /** Fires when the value has changed and the user exits the input. */ + @Event() valueChange: EventEmitter; + + /** Fires when the using is inputing data (on keystrokes). */ + @Event() valueInput: EventEmitter; + + /** Fires when the component needs to display more items in the suggestions. */ + @Event() needMoreItems: EventEmitter; + + /** Fires when the search query has changed. + * This is almost like valueInput, but it is debounced + * and can be used to trigger a search query without overloading + * API endpoints while typing. + */ + @Event() searchQueryChanged: EventEmitter; + + /** Fires when an item is selected. */ + @Event() itemSelected: EventEmitter; + + /** Reports the input validity details. See https://developer.mozilla.org/en-US/docs/Web/API/ValidityState */ + @Method() + async checkValidity(): Promise { + return this.inputField.validity; + } + + /** Can be used to set a custom validity message. */ + @Method() + async setCustomValidity(message: string): Promise { + this.customValidityMessage = message; + return this.inputField.setCustomValidity(message); + } + + @State() focused = false; + @State() valid = true; + @State() customValidityMessage: string; + @State() selectedIndex: number; + @State() positionInitialized = false; + @State() lastScrollTop = 0; + + /** attacth the internals for form validation */ + @AttachInternals() internals: ElementInternals; + + /** Listener for mouse down event */ + @Listen("click", { target: "document", capture: false }) + handleOutsideClick(e: MouseEvent) { + const path = e.composedPath(); + if (!path.includes(this.element)) + { + this.focused = false; + } + } + + componentDidRender(){ + if (this.focused && this.suggestions.length > 0 && !this.positionInitialized){ + this.adjustDropdownPosition(); + } + } + + private inputField!: HTMLInputElement; + private suggestionsContainer: HTMLUListElement; + private labelId: string; + + // eslint-disable-next-line @stencil-community/own-methods-must-be-private + formResetCallback() { + this.inputField.setCustomValidity(""); + this.valid = true; + this.value = ""; + this.internals.setValidity({}); + this.internals.setFormValue(""); + } + + private handleInput(e: Event) { + const value = (e.target as HTMLInputElement).value; + var valid = this.inputField.checkValidity(); + this.valid = valid; + this.valueInput.emit(value); + this.handleSearchQueryChanged(value); + } + + @Debounce(300) + private handleSearchQueryChanged(value: string) { + this.searchQueryChanged.emit(value); + } + + private handleInvalid(): void { + this.valid = false; + if (this.customValidityMessage == undefined) { + this.customValidityMessage = this.inputField.validationMessage; + } + } + + private handleChange() { + this.valueChange.emit(this.value); + if (this.name != undefined) { + var data = new FormData(); + data.append(this.name, this.value.toString()); + this.internals.setFormValue(data); + } + } + + /** Check if the label should float */ + private shouldLabelFloat(): boolean { + if (this.focused) { + return false; + } + + if (this.value != undefined && this.value != "") { + return false; + } + + return true; + } + + private findAverageSuggestionHeight(): number { + const suggestionItems = this.suggestionsContainer.querySelectorAll("li"); + var totalHeight = 0; + for (let i = 0; i < suggestionItems.length; i++) { + totalHeight += suggestionItems[i].clientHeight; + } + return totalHeight / suggestionItems.length; + } + + private readonly adjustDropdownPosition = () => { + var itemHeight = this.findAverageSuggestionHeight(); + requestAnimationFrame(() => { + this.positionInitialized = true; + }); + + // If we can fit 3 items below the input and there is still 3em left, we show the dropdown under. + // Otherwise, we show it above. + var spaceBelow = window.innerHeight - this.inputField.getBoundingClientRect().bottom; + const rem = parseFloat(getComputedStyle(document.documentElement).fontSize); + const fitsDown = spaceBelow > 3 * itemHeight + 3 * rem; + if (fitsDown) { + this.suggestionsContainer.style.top = "1.2rem"; + } + else { + this.suggestionsContainer.style.bottom = "1.2rem"; + } + + // Set the max height to not overflow the screen. + if (fitsDown){ + this.suggestionsContainer.style.maxHeight = `${spaceBelow - 3 * rem}px`; + } + else { + this.suggestionsContainer.style.maxHeight = `${this.inputField.getBoundingClientRect().top - 3 * rem}px`; + } + + this.checkIfMoreItemsNeeded(); + } + + private handleKeyDown(e: KeyboardEvent): void { + if (e.key === "ArrowDown") { + e.preventDefault(); + if (this.selectedIndex == undefined) { + this.selectedIndex = 0; + } else { + this.selectedIndex = Math.min(this.selectedIndex + 1, this.suggestions.length - 1); + } + } + if (e.key === "ArrowUp") { + e.preventDefault(); + if (this.selectedIndex == undefined) { + this.selectedIndex = this.suggestions.length - 1; + } else { + this.selectedIndex = Math.max(this.selectedIndex - 1, 0); + } + } + this.value = this.suggestions[this.selectedIndex]?.value; + if (e.key === "Enter") { + var selectedItem = this.suggestions[this.selectedIndex]; + this.value = selectedItem.value; + this.inputField.value = selectedItem.label; + this.itemSelected.emit(selectedItem.value); + this.focused = false; + } + if (e.key === "Tab"){ + this.focused = false; + } + } + + private selectItem(e: Event, index: number): void { + e.preventDefault(); + e.stopPropagation(); + this.selectedIndex = index; + this.value = this.suggestions[this.selectedIndex].value; + this.focused = false; + this.itemSelected.emit(this.suggestions[this.selectedIndex].value) + } + + private getVirtualScrollHeight(): number { + const itemHeight = this.findAverageSuggestionHeight(); + const upcomingItems = this.totalSuggestions - this.suggestions.length; + return itemHeight * upcomingItems; + } + + @Debounce(100) + private handleSuggestionsScroll(): void { + const container = this.suggestionsContainer; + const currentScrollTop = container.scrollTop; + + // Only act if we are scrolling down + if (currentScrollTop > this.lastScrollTop) { + const loadingDiv = container.querySelector('.loading') as HTMLDivElement; + + if (loadingDiv == undefined) { + this.lastScrollTop = currentScrollTop; + return; + } + + const loadingDivPosition = loadingDiv.offsetTop; + const loadingDivHeight = loadingDiv.offsetHeight; + const loadingDivBottom = loadingDivPosition + loadingDivHeight; + + // Calculate the visible bottom of the scroll container + const visibleBottom = currentScrollTop + container.clientHeight; + + // Prevent scrolling past the loading div by checking if the visible bottom surpasses the loading div's bottom + if (visibleBottom > loadingDivBottom) { + // Adjust scrollTop so it doesn't scroll past the loading div + container.scrollTop = loadingDivBottom - container.clientHeight; + } + + // Check if more items are needed based on the position of the loading div + this.checkIfMoreItemsNeeded(); + } + + // Update the last scroll position + this.lastScrollTop = currentScrollTop; + } + + @Debounce() + private checkIfMoreItemsNeeded() { + const container = this.suggestionsContainer; + + const loadingDiv = container.querySelector('.loading') as HTMLDivElement; + if (loadingDiv == undefined) return; // Exit if there's no loading div + + const scrollPosition = container.scrollTop + container.clientHeight; + const loadingDivPosition = loadingDiv.offsetTop; + + // Check if the loading div is within the threshold of becoming visible + if (loadingDivPosition - scrollPosition < this.preloadThresholdPixels) { + const eventArgs: NeedMoreItemsEventArgs = { + searchTerm: this.inputField.value, + }; + this.needMoreItems.emit(eventArgs); + } + } + + render() { + return ( + + +
+ this.inputField = el} + name={this.name} + type="search" + role="combobox" + aria-haspopup="listbox" + aria-expanded={this.focused.toString()} + aria-activedescendant={this.selectedIndex !== undefined ? `option-${this.selectedIndex}` : undefined} + disabled={this.disabled} + required={this.required} + autoComplete="off" + value={this.suggestions.length > 0 && this.selectedIndex != undefined ? this.suggestions[this.selectedIndex].label : this.value} + onFocus={() => this.focused = true} + onInput={e => this.handleInput(e)} + onInvalid={() => this.handleInvalid()} + onChange={() => this.handleChange()} + aria-labelledby={this.labelId} + onKeyDown={e => this.handleKeyDown(e)} + /> +
    0 ? "show" : ""} + role="listbox" + ref={el => this.suggestionsContainer = el} + onScroll={() => this.handleSuggestionsScroll()} + > + {this.suggestions.map((suggestion, index) => ( +
  • this.selectItem(e, index)} + > + {this.renderSuggestion != undefined ? this.renderSuggestion(suggestion) : suggestion.label} +
  • + ))} + {this.totalSuggestions != undefined && this.totalSuggestions > this.suggestions.length && +
    +
    + } + {this.totalSuggestions != undefined && this.totalSuggestions > this.suggestions.length && this.positionInitialized && +
    +
    + } +
+ this.focused = !this.focused} + class="chevron-down" + xmlns="http://www.w3.org/2000/svg" + viewBox="0 -960 960 960"> + + +
+
+
+ ); + } +} \ No newline at end of file diff --git a/packages/stencil-library/src/components/dnn-autocomplete/dnn-button.stories.ts b/packages/stencil-library/src/components/dnn-autocomplete/dnn-button.stories.ts new file mode 100644 index 000000000..187fe0e9d --- /dev/null +++ b/packages/stencil-library/src/components/dnn-autocomplete/dnn-button.stories.ts @@ -0,0 +1,88 @@ +import type { Meta, StoryObj } from '@storybook/web-components'; +import { html, LitElement } from 'lit'; +import { customElement, state } from 'lit/decorators.js'; +import { ifDefined } from 'lit-html/directives/if-defined.js'; +import { unsafeHTML } from 'lit-html/directives/unsafe-html.js'; +import { actions } from '@storybook/addon-actions'; +import readme from "./readme.md"; + +const meta: Meta = { + title: 'Elements/AutoComplete', + component: 'dnn-autocomplete', + tags: ['autodocs'], + parameters: { + docs: { + description: { + component: readme, + } + } + }, + argTypes: { + disabled: { + control: 'boolean', + }, + helpText: { + control: 'text', + }, + label: { + control: 'text', + }, + name: { + control: 'text', + }, + preloadThresholdPixels:{ + control: 'number', + }, + renderSuggestions: { + control: 'object', + }, + required: { + control: 'boolean', + }, + suggestions: { + control: 'object', + }, + totalSuggestions: { + control: 'number', + }, + value: { + control: 'text', + }, + onSearchQueryChanged: { + action: 'onSearchQueryChanged', + } + }, +}; +export default meta; + +const eventsFromNames = actions('itemSelected', 'needMoreItems', 'searchQueryChanged', 'valueChange', 'valueInput'); + +const Template = (args) => + html` + eventsFromNames.itemSelected(e)} + @needMoreItems=${e => eventsFromNames.needMoreItems(e)} + @searchQueryChanged=${args.onSearchQueryChanged ? args.onSearchQueryChanged : e => eventsFromNames.searchQueryChanged(e)} + @valueChange=${e => eventsFromNames.valueChange(e)} + @valueInput=${e => eventsFromNames.valueInput(e)} + > + + `; + + +type Story = StoryObj; + +export const Primary : Story = Template.bind({}); +Primary.args = { + label: "Autocomplete", +}; \ No newline at end of file diff --git a/packages/stencil-library/src/components/dnn-autocomplete/readme.md b/packages/stencil-library/src/components/dnn-autocomplete/readme.md new file mode 100644 index 000000000..d5aa60b71 --- /dev/null +++ b/packages/stencil-library/src/components/dnn-autocomplete/readme.md @@ -0,0 +1,317 @@ +# dnn-autocomplete + +Building a component that is flexible enough for multiple use cases is not easy. This component externalizes some of its behavior to make it more reusable. To use it effectivelly please read the usage examples carefuly. + +## Most Basic Usage +You need to at least provide the list of items and refresh the results with your own logic for the searched terms. Suggestions are not just strings, they are an object with a `value` and a `label`. If you are in a typed environment, `DnnAutocompleteSuggestion` is the type to use for individual suggestions. The label is what shows to the user and the value is what gets posted in both events and form values. The `onSearchQueryChanged` event lets you know to refresh your list of suggestions. + +Data can be a harcoded list or an API call or whatever makes sense for your use case. + +## Customizing the display of items +We have made it so the consumer can totally customize how suggestions are displayed, this is a very powerful feature that makes this component very reusable. If not customized, the out-of-box experience is to just display the label in plain text in the dropdown. But you can hook into the `renderSuggestion` callback to override that default behavior. You receive the suggestion item and return the html to display. + + + + +## Usage + +### HTML + +#### Most Basic Usage +```html + + + this.handleSearchQueryChanged(e.detail)} +/> +``` + +#### Customizing the display of items +```html + + + +``` + +#### Using a paging API +```html + + + +``` + + +### JSX-TSX + +#### Most Basic Usage +```tsx +const suggestions = +[ + { value: "1", label: "johnsmith" }, + { value: "2", label: "sarahjones" }, + { value: "3", label: "mikeross" }, + { value: "4", label: "emilyclark" }, + { value: "5", label: "davemiller" }, + { value: "6", label: "lindagreen" }, + { value: "7", label: "chrisevans" }, + { value: "8", label: "lisawhite" }, + { value: "9", label: "tomharris" }, + { value: "10", label: "jennymoore" } +]; + +let filteredSuggestions = suggestions; + +private handleSearchQueryChanged(query){ + if (query == undefined || query == ""){ + this.filteredSuggestions = this.suggestions; + return; + } + this.filteredSuggestions = this.suggestions.filter(user => + user.label.toLowerCase().includes(query.toLowerCase)); +} + +render(){ + return( + this.handleSearchQueryChanged(e.detail)} + /> + ) +} +``` + +#### Customizing the display of items +```tsx +private handleRenderSuggestion(suggestion){ + var user = this.getUserDetails(suggestion.value) + return( +
+ + {user.firstName} {user.lastName} +
+ ); +} + +private getUserDetails(userId){ + return // Some logic that returns a user object... +} + +render(){ + return( + this.handleRenderSuggestion(suggestion)} + /> + ); +} +``` + +#### Using a paging API +```tsx +let lastFetchedPage = 0; +let totalSuggesitons = 0; +let suggestions = []; + +private handleSearchChanged(query){ + fetch(`https://some.endpoint.com/search/${query}`) + .then(response => response.json()) + .then(data => { + this.lastFetchedPage = 1; + this.totalSuggestion = data.totalResults; + this.suggestions = data.results; + }); +} + +private handleLoadMore(query){ + fetch(`https://some.endpoint.com/search/${query}/page=${this.lastFetchedPage + 1}`) + .then(response => response.json()) + .then(data => { + this.lastFetchedPage++; + this.suggestions = [...this.suggestions, data.results]; + }); +} + + { + this.handleSearchChanged(e.detail); + }} + onNeedMoreItems={e => this.loadMore(e.detail.searchTerm)} +/> +``` + + + +## Properties + +| Property | Attribute | Description | Type | Default | +| ------------------------ | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- | ----------- | +| `disabled` | `disabled` | Defines whether the field is disabled. | `boolean` | `undefined` | +| `helpText` | `help-text` | Defines the help label displayed under the field. | `string` | `undefined` | +| `label` | `label` | The label for this autocomplete. | `string` | `undefined` | +| `name` | `name` | The name for this autocomplete when used in forms. | `string` | `undefined` | +| `preloadThresholdPixels` | `preload-threshold-pixels` | How many suggestions to preload in pixels of their height. This is used to calculate the virtual scroll height and request more items before they get into view. | `number` | `1000` | +| `renderSuggestion` | -- | Callback to render suggestions, if not provided, only the label will be rendered. | `(suggestion: DnnAutocompleteSuggestion) => HTMLElement` | `undefined` | +| `required` | `required` | Defines whether the field requires having a value. | `boolean` | `undefined` | +| `suggestions` | -- | Sets the list of suggestions. | `DnnAutocompleteSuggestion[]` | `[]` | +| `totalSuggestions` | `total-suggestions` | The total amount of suggestions for the given search query. This can be used to show virtual scroll and pagination progressive feeding. The needMoreItems event should be used to request more items. | `number` | `undefined` | +| `value` | `value` | Defines the value for this autocomplete | `string` | `undefined` | + + +## Events + +| Event | Description | Type | +| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------- | +| `itemSelected` | Fires when an item is selected. | `CustomEvent` | +| `needMoreItems` | Fires when the component needs to display more items in the suggestions. | `CustomEvent` | +| `searchQueryChanged` | Fires when the search query has changed. This is almost like valueInput, but it is debounced and can be used to trigger a search query without overloading API endpoints while typing. | `CustomEvent` | +| `valueChange` | Fires when the value has changed and the user exits the input. | `CustomEvent` | +| `valueInput` | Fires when the using is inputing data (on keystrokes). | `CustomEvent` | + + +## Methods + +### `checkValidity() => Promise` + +Reports the input validity details. See https://developer.mozilla.org/en-US/docs/Web/API/ValidityState + +#### Returns + +Type: `Promise` + + + +### `setCustomValidity(message: string) => Promise` + +Can be used to set a custom validity message. + +#### Parameters + +| Name | Type | Description | +| --------- | -------- | ----------- | +| `message` | `string` | | + +#### Returns + +Type: `Promise` + + + + +## CSS Custom Properties + +| Name | Description | +| -------------------- | ------------------------------------------------ | +| `--background-color` | Defines the background color. | +| `--control-radius` | Defines the radius for the control corners. | +| `--danger-color` | Defines the danger color used for invalid data. | +| `--focus-color` | Defines the color when the component is focused. | +| `--foreground-color` | Defines the foreground color. | + + +## Dependencies + +### Used by + + - [dnn-example-form](../examples/dnn-example-form) + +### Depends on + +- [dnn-fieldset](../dnn-fieldset) + +### Graph +```mermaid +graph TD; + dnn-autocomplete --> dnn-fieldset + dnn-example-form --> dnn-autocomplete + style dnn-autocomplete fill:#f9f,stroke:#333,stroke-width:4px +``` + +---------------------------------------------- + +*Built with [StencilJS](https://stenciljs.com/)* diff --git a/packages/stencil-library/src/components/dnn-autocomplete/types.ts b/packages/stencil-library/src/components/dnn-autocomplete/types.ts new file mode 100644 index 000000000..84fde2449 --- /dev/null +++ b/packages/stencil-library/src/components/dnn-autocomplete/types.ts @@ -0,0 +1,13 @@ +/** Represents a single autocomplete suggestion */ +export interface DnnAutocompleteSuggestion { + /** That value that represents this entry (must be unique) like an ID. */ + value: string; + + /** The label to display to the user. */ + label: string; +}; + +export interface NeedMoreItemsEventArgs { + /** The current search term. */ + searchTerm: string; +} \ No newline at end of file diff --git a/packages/stencil-library/src/components/dnn-autocomplete/usage/HTML.md b/packages/stencil-library/src/components/dnn-autocomplete/usage/HTML.md new file mode 100644 index 000000000..f070966e5 --- /dev/null +++ b/packages/stencil-library/src/components/dnn-autocomplete/usage/HTML.md @@ -0,0 +1,105 @@ +#### Most Basic Usage +```html + + + this.handleSearchQueryChanged(e.detail)} +/> +``` + +#### Customizing the display of items +```html + + + +``` + +#### Using a paging API +```html + + + +``` diff --git a/packages/stencil-library/src/components/dnn-autocomplete/usage/JSX-TSX.md b/packages/stencil-library/src/components/dnn-autocomplete/usage/JSX-TSX.md new file mode 100644 index 000000000..173cd1811 --- /dev/null +++ b/packages/stencil-library/src/components/dnn-autocomplete/usage/JSX-TSX.md @@ -0,0 +1,98 @@ +#### Most Basic Usage +```tsx +const suggestions = +[ + { value: "1", label: "johnsmith" }, + { value: "2", label: "sarahjones" }, + { value: "3", label: "mikeross" }, + { value: "4", label: "emilyclark" }, + { value: "5", label: "davemiller" }, + { value: "6", label: "lindagreen" }, + { value: "7", label: "chrisevans" }, + { value: "8", label: "lisawhite" }, + { value: "9", label: "tomharris" }, + { value: "10", label: "jennymoore" } +]; + +let filteredSuggestions = suggestions; + +private handleSearchQueryChanged(query){ + if (query == undefined || query == ""){ + this.filteredSuggestions = this.suggestions; + return; + } + this.filteredSuggestions = this.suggestions.filter(user => + user.label.toLowerCase().includes(query.toLowerCase)); +} + +render(){ + return( + this.handleSearchQueryChanged(e.detail)} + /> + ) +} +``` + +#### Customizing the display of items +```tsx +private handleRenderSuggestion(suggestion){ + var user = this.getUserDetails(suggestion.value) + return( +
+ + {user.firstName} {user.lastName} +
+ ); +} + +private getUserDetails(userId){ + return // Some logic that returns a user object... +} + +render(){ + return( + this.handleRenderSuggestion(suggestion)} + /> + ); +} +``` + +#### Using a paging API +```tsx +let lastFetchedPage = 0; +let totalSuggesitons = 0; +let suggestions = []; + +private handleSearchChanged(query){ + fetch(`https://some.endpoint.com/search/${query}`) + .then(response => response.json()) + .then(data => { + this.lastFetchedPage = 1; + this.totalSuggestion = data.totalResults; + this.suggestions = data.results; + }); +} + +private handleLoadMore(query){ + fetch(`https://some.endpoint.com/search/${query}/page=${this.lastFetchedPage + 1}`) + .then(response => response.json()) + .then(data => { + this.lastFetchedPage++; + this.suggestions = [...this.suggestions, data.results]; + }); +} + + { + this.handleSearchChanged(e.detail); + }} + onNeedMoreItems={e => this.loadMore(e.detail.searchTerm)} +/> +``` diff --git a/packages/stencil-library/src/components/dnn-fieldset/dnn-fieldset.scss b/packages/stencil-library/src/components/dnn-fieldset/dnn-fieldset.scss index 62507cd6c..dee1cdc9e 100644 --- a/packages/stencil-library/src/components/dnn-fieldset/dnn-fieldset.scss +++ b/packages/stencil-library/src/components/dnn-fieldset/dnn-fieldset.scss @@ -51,6 +51,7 @@ font-size: 1em; margin-top: 1em; z-index: 1; + pointer-events: none; } &.focused{ border: 1px solid var(--fieldset-focus-color); diff --git a/packages/stencil-library/src/components/dnn-fieldset/readme.md b/packages/stencil-library/src/components/dnn-fieldset/readme.md index 9cf5f7ed0..8bb08455e 100644 --- a/packages/stencil-library/src/components/dnn-fieldset/readme.md +++ b/packages/stencil-library/src/components/dnn-fieldset/readme.md @@ -125,6 +125,7 @@ Type: `Promise` ### Used by + - [dnn-autocomplete](../dnn-autocomplete) - [dnn-color-input](../dnn-color-input) - [dnn-example-form](../examples/dnn-example-form) - [dnn-input](../dnn-input) @@ -134,6 +135,7 @@ Type: `Promise` ### Graph ```mermaid graph TD; + dnn-autocomplete --> dnn-fieldset dnn-color-input --> dnn-fieldset dnn-example-form --> dnn-fieldset dnn-input --> dnn-fieldset diff --git a/packages/stencil-library/src/components/examples/dnn-example-form/dnn-example-form.tsx b/packages/stencil-library/src/components/examples/dnn-example-form/dnn-example-form.tsx index 1234c65bf..d1510a9c2 100644 --- a/packages/stencil-library/src/components/examples/dnn-example-form/dnn-example-form.tsx +++ b/packages/stencil-library/src/components/examples/dnn-example-form/dnn-example-form.tsx @@ -1,4 +1,5 @@ import { Component, Host, h, State } from '@stencil/core'; +import { DnnAutocompleteSuggestion } from '../../dnn-autocomplete/types'; /** Do not use this component in production, it is meant for testing purposes only and is not distributed in the production package. */ @Component({ @@ -11,6 +12,94 @@ export class DnnExampleForm { @State() profilePicConfirmed = false; private fieldset: HTMLDnnFieldsetElement; + private characterPicker: HTMLDnnAutocompleteElement; + + @State() filteredUsers: DnnAutocompleteSuggestion[] = []; + + private users: DnnAutocompleteSuggestion[] = [ + { + value: "1", + label: "Daniel Valadas : @valadas", + }, + { + value: "2", + label: "Brian Dukes : @bdukes", + }, + { + value: "3", + label: "David Poindexter : @david-poindexter", + }, + { + value: "4", + label: "Mitchel Sellers : @mitchelsellers", + } + ]; + + + private characters = []; + private charactersAbortController: AbortController; + private lastFetchedPage = 0; + + private searchCharacters = async (search: string, page: number) => { + + // Abort any ongoing fetch to prevent a race condition. + if (this.charactersAbortController != undefined) { + this.charactersAbortController.abort(); + } + this.charactersAbortController = new AbortController(); + + try{ + const response = await fetch( + `https://rickandmortyapi.com/api/character?name=${encodeURIComponent(search)}&page=${page}`, + { + signal: this.charactersAbortController.signal, + }); + if (response.ok){ + return await response.json(); + } + } + catch (error) { + if (error.name != "AbortError") { + // Handle the error unless it is a normal AbortError which we ignore. + // eslint-disable-next-line no-console + console.error(error); + } + } + } + + private handleCharacterSearchChanged(search: string) { + if (search == undefined || search == "") { + this.characterPicker.suggestions = []; + this.characterPicker.totalSuggestions = 0; + return; + } + + this.searchCharacters(search, 1) + .then(result => { + this.characters = result.results; + var suggestions: DnnAutocompleteSuggestion[] = result.results.map(r => ({ + value: r.id, + label: r.name, + })); + this.characterPicker.suggestions = suggestions; + this.characterPicker.totalSuggestions = result.info.count; + this.lastFetchedPage = 1; + }); + } + + private loadMoreCharacters(searchTerm: string): void { + this.lastFetchedPage++; + this.searchCharacters(searchTerm, this.lastFetchedPage) + .then(result => { + this.characters = [...this.characters, ...result.results]; + var suggestions: DnnAutocompleteSuggestion[] = this.characters.map(r => ({ + value: r.id, + label: r.name, + })); + this.characterPicker.suggestions = suggestions; + this.characterPicker.totalSuggestions = result.info.count; + }); + } private resumeDropped(detail: File[]): void { var singleFile = detail[0]; @@ -185,6 +274,61 @@ export class DnnExampleForm { Subscribe to our newsletter + { + if (e.detail == undefined || e.detail == "") + { + this.filteredUsers = []; + return; + } + const search = (e.detail as string).toLowerCase(); + this.filteredUsers = this.users.filter(u => u.label.toLowerCase().includes(search)); + }} + renderSuggestion={suggestion => +
+ {suggestion.label} +
+ {suggestion.label.split(":")[0]} + {suggestion.label.split(":").pop().trim()} +
+
+ } + /> + this.characterPicker = el} + label="Favorite Character" + helpText="Select your favorite Rick and Morty character" + renderSuggestion={suggestion =>{ + const character = this.characters.find(r => r.id === suggestion.value); + return
+ {character.name} +
+ {character.name} +
{character.species} / {character.gender} / {character.status}
+
Location: {character.location.name}
+
Origin: {character.origin.name}
+
+
+ }} + onSearchQueryChanged={e => { + this.handleCharacterSearchChanged(e.detail as string); + }} + onNeedMoreItems={e => this.loadMoreCharacters(e.detail.searchTerm)} + /> {this.resume === undefined && this.resumeDropped(e.detail)} /> diff --git a/packages/stencil-library/src/components/examples/dnn-example-form/readme.md b/packages/stencil-library/src/components/examples/dnn-example-form/readme.md index dc4e31af4..f45860ba9 100644 --- a/packages/stencil-library/src/components/examples/dnn-example-form/readme.md +++ b/packages/stencil-library/src/components/examples/dnn-example-form/readme.md @@ -20,6 +20,7 @@ Do not use this component in production, it is meant for testing purposes only a - [dnn-select](../../dnn-select) - [dnn-textarea](../../dnn-textarea) - [dnn-toggle](../../dnn-toggle) +- [dnn-autocomplete](../../dnn-autocomplete) - [dnn-dropzone](../../dnn-dropzone) - [dnn-button](../../dnn-button) - [dnn-image-cropper](../../dnn-image-cropper) @@ -36,6 +37,7 @@ graph TD; dnn-example-form --> dnn-select dnn-example-form --> dnn-textarea dnn-example-form --> dnn-toggle + dnn-example-form --> dnn-autocomplete dnn-example-form --> dnn-dropzone dnn-example-form --> dnn-button dnn-example-form --> dnn-image-cropper @@ -52,6 +54,7 @@ graph TD; dnn-button --> dnn-button dnn-select --> dnn-fieldset dnn-textarea --> dnn-fieldset + dnn-autocomplete --> dnn-fieldset dnn-image-cropper --> dnn-dropzone dnn-image-cropper --> dnn-modal style dnn-example-form fill:#f9f,stroke:#333,stroke-width:4px diff --git a/packages/stencil-library/src/index.html b/packages/stencil-library/src/index.html index 152cf1bf3..421acc00d 100644 --- a/packages/stencil-library/src/index.html +++ b/packages/stencil-library/src/index.html @@ -77,6 +77,7 @@

Dnn HTML custom elements

+ + +
+

dnn-autocomplete

+ + +
+
diff --git a/packages/stencil-library/vscode-data.json b/packages/stencil-library/vscode-data.json index ac0c06249..d6e417ab3 100644 --- a/packages/stencil-library/vscode-data.json +++ b/packages/stencil-library/vscode-data.json @@ -1,6 +1,47 @@ { "version": 1.1, "tags": [ + { + "name": "dnn-autocomplete", + "description": { + "kind": "markdown", + "value": "Building a component that is flexible enough for multiple use cases is not easy. This component externalizes some of its behavior to make it more reusable. To use it effectivelly please read the usage examples carefuly." + }, + "attributes": [ + { + "name": "disabled", + "description": "Defines whether the field is disabled." + }, + { + "name": "help-text", + "description": "Defines the help label displayed under the field." + }, + { + "name": "label", + "description": "The label for this autocomplete." + }, + { + "name": "name", + "description": "The name for this autocomplete when used in forms." + }, + { + "name": "preload-threshold-pixels", + "description": "How many suggestions to preload in pixels of their height.\nThis is used to calculate the virtual scroll height and request\nmore items before they get into view." + }, + { + "name": "required", + "description": "Defines whether the field requires having a value." + }, + { + "name": "total-suggestions", + "description": "The total amount of suggestions for the given search query.\nThis can be used to show virtual scroll and pagination progressive feeding.\nThe needMoreItems event should be used to request more items." + }, + { + "name": "value", + "description": "Defines the value for this autocomplete" + } + ] + }, { "name": "dnn-button", "description": { @@ -30,7 +71,7 @@ }, { "name": "form-button-type", - "description": "Optional button type,\r\ncan be either submit, reset or button and defaults to button if not specified.\r\nWarning: DNN wraps the whole page in a form, only use this if you are handling\r\nform submission manually.", + "description": "Optional button type,\ncan be either submit, reset or button and defaults to button if not specified.\nWarning: DNN wraps the whole page in a form, only use this if you are handling\nform submission manually.", "values": [ { "name": "button" @@ -64,7 +105,7 @@ }, { "name": "type", - "description": "Optional button style,\r\ncan be either primary, secondary or tertiary or danger and defaults to primary if not specified", + "description": "Optional button style,\ncan be either primary, secondary or tertiary or danger and defaults to primary if not specified", "values": [ { "name": "danger" @@ -235,11 +276,11 @@ "attributes": [ { "name": "allow-camera-mode", - "description": "If true, will allow the user to take a snapshot\r\nusing the device camera. (only works over https)." + "description": "If true, will allow the user to take a snapshot\nusing the device camera. (only works over https)." }, { "name": "capture-quality", - "description": "Specifies the jpeg quality for when the device\r\ncamera is used to generate a picture.\r\nNeeds to be a number between 0 and 1 and defaults to 0.8" + "description": "Specifies the jpeg quality for when the device\ncamera is used to generate a picture.\nNeeds to be a number between 0 and 1 and defaults to 0.8" }, { "name": "max-file-size", @@ -320,7 +361,7 @@ "name": "dnn-image-cropper", "description": { "kind": "markdown", - "value": "Allows cropping an image in-browser with the option to enforce a specific final size.\r\nAll computation happens in the browser and the final image is emmited\r\nin an event that has a data-url of the image." + "value": "Allows cropping an image in-browser with the option to enforce a specific final size.\nAll computation happens in the browser and the final image is emmited\nin an event that has a data-url of the image." }, "attributes": [ { @@ -471,7 +512,7 @@ }, { "name": "close-text", - "description": "Optionally pass the aria-label text for the close button.\r\nDefaults to \"Close modal\" if not provided." + "description": "Optionally pass the aria-label text for the close button.\nDefaults to \"Close modal\" if not provided." }, { "name": "resizable", @@ -479,7 +520,7 @@ }, { "name": "show-close-button", - "description": "Optionally you can pass false to not show the close button.\r\nIf you decide to do so, you should either not also prevent dismissal by clicking the backdrop\r\nor provide your own dismissal logic in the modal content." + "description": "Optionally you can pass false to not show the close button.\nIf you decide to do so, you should either not also prevent dismissal by clicking the backdrop\nor provide your own dismissal logic in the modal content." }, { "name": "visible", @@ -896,7 +937,7 @@ "name": "dnn-vertical-splitview", "description": { "kind": "markdown", - "value": "This allows splitting a UI into vertical adjustable panels, the splitter itself is not part of this component.\r\n- The content for the left part should be injected in the `left` slot.\r\n- The content for the right part should be injected in the `right` slot.\r\n- The content for the actual splitter should go in the default slot and other UI elements can be injected as long as you handle their behaviour, by default only the drag behavior is implemented in the component." + "value": "This allows splitting a UI into vertical adjustable panels, the splitter itself is not part of this component.\n- The content for the left part should be injected in the `left` slot.\n- The content for the right part should be injected in the `right` slot.\n- The content for the actual splitter should go in the default slot and other UI elements can be injected as long as you handle their behaviour, by default only the drag behavior is implemented in the component." }, "attributes": [ {