From e3b76e32ecfe4152242b9568b977d0ada42648ca Mon Sep 17 00:00:00 2001 From: Tim Sielemann Date: Fri, 18 Oct 2024 16:50:52 +0200 Subject: [PATCH] feat(core): enable tag inputs without dropdowns (#586) * feat(core): enable tag inputs without dropdowns * feat(core): enable tag inputs without dropdowns * feat(core): add cat-tag component for tag inputs without search * Revert "feat(core): enable tag inputs without dropdowns" This reverts commit c737b0f95c0583340711da5b6a36e167266e14b9. * Revert "feat(core): enable tag inputs without dropdowns" This reverts commit 440fa096 * fix(core): fix pipeline * fix(core): review improvements * fix(core): review improvements --------- Co-authored-by: Tim Sielemann --- .../catalyst-demo/src/app/app.component.html | 11 + .../catalyst-demo/src/app/app.component.ts | 6 + .../catalyst/src/lib/catalyst.module.ts | 3 +- .../catalyst/src/lib/directives/proxies.ts | 70 ++++ .../lib/directives/select-value-accessor.ts | 2 +- .../directives/value-accessor-decorator.ts | 2 +- core/src/components.d.ts | 162 +++++++++ core/src/components/cat-button/readme.md | 2 + core/src/components/cat-icon/readme.md | 2 + core/src/components/cat-tag/cat-tag.e2e.ts | 16 + core/src/components/cat-tag/cat-tag.scss | 123 +++++++ core/src/components/cat-tag/cat-tag.spec.tsx | 23 ++ core/src/components/cat-tag/cat-tag.tsx | 324 ++++++++++++++++++ core/src/components/cat-tag/readme.md | 65 ++++ core/src/index.html | 6 + core/stencil.config.ts | 2 +- pnpm-lock.yaml | 39 --- .../src/components/stencil-generated/index.ts | 1 + vue/src/components.ts | 22 ++ 19 files changed, 838 insertions(+), 43 deletions(-) create mode 100644 core/src/components/cat-tag/cat-tag.e2e.ts create mode 100644 core/src/components/cat-tag/cat-tag.scss create mode 100644 core/src/components/cat-tag/cat-tag.spec.tsx create mode 100644 core/src/components/cat-tag/cat-tag.tsx create mode 100644 core/src/components/cat-tag/readme.md diff --git a/angular/projects/catalyst-demo/src/app/app.component.html b/angular/projects/catalyst-demo/src/app/app.component.html index 26e23f784..d2ba54cff 100644 --- a/angular/projects/catalyst-demo/src/app/app.component.html +++ b/angular/projects/catalyst-demo/src/app/app.component.html @@ -116,4 +116,15 @@

new datetime picker

abxcasds + +
TEST SLOTTED LABEL
+ + + + diff --git a/angular/projects/catalyst-demo/src/app/app.component.ts b/angular/projects/catalyst-demo/src/app/app.component.ts index 339f23d76..3776c1f57 100644 --- a/angular/projects/catalyst-demo/src/app/app.component.ts +++ b/angular/projects/catalyst-demo/src/app/app.component.ts @@ -36,6 +36,8 @@ export class AppComponent implements OnInit { datepickerDisabled: new FormControl(true) }); + tagFormControl = new FormControl(['tag1', 'tag2'], [Validators.required]); + countryConnector = countryConnector; fields: FormlyFieldConfig[] = [ @@ -182,4 +184,8 @@ export class AppComponent implements OnInit { panelClass: ['cat-panel'] }); } + + logChanges($event: any) { + console.log($event); + } } diff --git a/angular/projects/catalyst/src/lib/catalyst.module.ts b/angular/projects/catalyst/src/lib/catalyst.module.ts index fbfa7c237..b03bb5e5e 100644 --- a/angular/projects/catalyst/src/lib/catalyst.module.ts +++ b/angular/projects/catalyst/src/lib/catalyst.module.ts @@ -43,7 +43,8 @@ const CatComponents = [ Components.CatTextarea, Components.CatTime, Components.CatToggle, - Components.CatTooltip + Components.CatTooltip, + Components.CatTag ]; const CatDirectives = [ diff --git a/angular/projects/catalyst/src/lib/directives/proxies.ts b/angular/projects/catalyst/src/lib/directives/proxies.ts index 55cace5db..1a7f43704 100644 --- a/angular/projects/catalyst/src/lib/directives/proxies.ts +++ b/angular/projects/catalyst/src/lib/directives/proxies.ts @@ -1160,6 +1160,76 @@ export declare interface CatTabs extends Components.CatTabs { catChange: EventEmitter>; } +@ProxyCmp({ + inputs: [ + 'clearable', + 'disabled', + 'errorUpdate', + 'errors', + 'hint', + 'identifier', + 'label', + 'labelHidden', + 'name', + 'nativeAttributes', + 'placeholder', + 'required', + 'requiredMarker', + 'tagCreationChars', + 'value' + ] +}) +@Component({ + selector: 'cat-tag', + changeDetection: ChangeDetectionStrategy.OnPush, + template: '', + // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property + inputs: [ + 'clearable', + 'disabled', + 'errorUpdate', + 'errors', + 'hint', + 'identifier', + 'label', + 'labelHidden', + 'name', + 'nativeAttributes', + 'placeholder', + 'required', + 'requiredMarker', + 'tagCreationChars', + 'value' + ] +}) +export class CatTag { + protected el: HTMLElement; + constructor( + c: ChangeDetectorRef, + r: ElementRef, + protected z: NgZone + ) { + c.detach(); + this.el = r.nativeElement; + proxyOutputs(this, this.el, ['catChange', 'catFocus', 'catBlur']); + } +} + +export declare interface CatTag extends Components.CatTag { + /** + * Emitted when the value is changed. + */ + catChange: EventEmitter>; + /** + * Emitted when the input received focus. + */ + catFocus: EventEmitter>; + /** + * Emitted when the input loses focus. + */ + catBlur: EventEmitter>; +} + @ProxyCmp({ inputs: [ 'disabled', diff --git a/angular/projects/catalyst/src/lib/directives/select-value-accessor.ts b/angular/projects/catalyst/src/lib/directives/select-value-accessor.ts index 463bf3e69..7bacefcaa 100644 --- a/angular/projects/catalyst/src/lib/directives/select-value-accessor.ts +++ b/angular/projects/catalyst/src/lib/directives/select-value-accessor.ts @@ -5,7 +5,7 @@ import { ValueAccessor } from './value-accessor'; @Directive({ /* tslint:disable-next-line:directive-selector */ - selector: 'cat-select', + selector: 'cat-select, cat-tag', host: { '(catChange)': 'handleChangeEvent($event.target.value)' }, diff --git a/angular/projects/catalyst/src/lib/directives/value-accessor-decorator.ts b/angular/projects/catalyst/src/lib/directives/value-accessor-decorator.ts index 60a44f101..2cff8a916 100644 --- a/angular/projects/catalyst/src/lib/directives/value-accessor-decorator.ts +++ b/angular/projects/catalyst/src/lib/directives/value-accessor-decorator.ts @@ -3,7 +3,7 @@ import { ControlContainer, NgControl, Validators } from '@angular/forms'; @Directive({ /* tslint:disable-next-line:directive-selector */ - selector: 'cat-input, cat-textarea, cat-datepicker, cat-select, cat-date, cat-time', + selector: 'cat-input, cat-textarea, cat-datepicker, cat-select, cat-date, cat-time, cat-tag', host: { '(catBlur)': 'updateErrors()' } diff --git a/core/src/components.d.ts b/core/src/components.d.ts index d5a29e326..888746e3f 100644 --- a/core/src/components.d.ts +++ b/core/src/components.d.ts @@ -1262,6 +1262,68 @@ export namespace Components { */ "tabsAlign": 'left' | 'center' | 'right' | 'justify'; } + interface CatTag { + /** + * Whether the input should show a clear button. + */ + "clearable": boolean; + /** + * Whether the select is disabled. + */ + "disabled": boolean; + /** + * Fine-grained control over when the errors are shown. Can be `false` to never show errors, `true` to show errors on blur, or a number to show errors on change with the given delay in milliseconds. + */ + "errorUpdate": boolean | number; + /** + * The validation errors for this input. Will render a hint under the input with the translated error message(s) `error.${key}`. If an object is passed, the keys will be used as error keys and the values translation parameters. If the value is `true`, the input will be marked as invalid without any hints under the input. + */ + "errors"?: boolean | string[] | ErrorMap; + /** + * Optional hint text(s) to be displayed with the select. + */ + "hint"?: string | string[]; + /** + * A unique identifier for the input. + */ + "identifier"?: string; + /** + * The label for the select. + */ + "label": string; + /** + * Visually hide the label, but still show it to assistive technologies like screen readers. + */ + "labelHidden": boolean; + /** + * The name of the form control. Submitted with the form as part of a name/value pair. + */ + "name"?: string; + /** + * Attributes that will be added to the native HTML input element. + */ + "nativeAttributes"?: { [key: string]: string }; + /** + * The placeholder text to display within the select. + */ + "placeholder"?: string; + /** + * A value is required or must be checked for the form to be submittable. + */ + "required": boolean; + /** + * Whether the label need a marker to shown if the select is required or optional. + */ + "requiredMarker"?: 'none' | 'required' | 'optional' | 'none!' | 'optional!' | 'required!'; + /** + * List of characters that should create a new tag. This need to be comparable to `keydownEvent.key`. Pasted values will also be split by those chars. Defaults to `[' ']`. + */ + "tagCreationChars": string[]; + /** + * The value of the control. + */ + "value"?: string[]; + } /** * Textarea specifies a control that allows user to write text over multiple * rows. Used when the expected user input is long. For shorter input, use the @@ -1652,6 +1714,10 @@ export interface CatTabsCustomEvent extends CustomEvent { detail: T; target: HTMLCatTabsElement; } +export interface CatTagCustomEvent extends CustomEvent { + detail: T; + target: HTMLCatTagElement; +} export interface CatTextareaCustomEvent extends CustomEvent { detail: T; target: HTMLCatTextareaElement; @@ -2096,6 +2162,25 @@ declare global { prototype: HTMLCatTabsElement; new (): HTMLCatTabsElement; }; + interface HTMLCatTagElementEventMap { + "catChange": string[]; + "catFocus": FocusEvent; + "catBlur": FocusEvent; + } + interface HTMLCatTagElement extends Components.CatTag, HTMLStencilElement { + addEventListener(type: K, listener: (this: HTMLCatTagElement, ev: CatTagCustomEvent) => 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: HTMLCatTagElement, ev: CatTagCustomEvent) => 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 HTMLCatTagElement: { + prototype: HTMLCatTagElement; + new (): HTMLCatTagElement; + }; interface HTMLCatTextareaElementEventMap { "catChange": string; "catFocus": FocusEvent; @@ -2202,6 +2287,7 @@ declare global { "cat-spinner": HTMLCatSpinnerElement; "cat-tab": HTMLCatTabElement; "cat-tabs": HTMLCatTabsElement; + "cat-tag": HTMLCatTagElement; "cat-textarea": HTMLCatTextareaElement; "cat-time": HTMLCatTimeElement; "cat-toggle": HTMLCatToggleElement; @@ -3464,6 +3550,80 @@ declare namespace LocalJSX { */ "tabsAlign"?: 'left' | 'center' | 'right' | 'justify'; } + interface CatTag { + /** + * Whether the input should show a clear button. + */ + "clearable"?: boolean; + /** + * Whether the select is disabled. + */ + "disabled"?: boolean; + /** + * Fine-grained control over when the errors are shown. Can be `false` to never show errors, `true` to show errors on blur, or a number to show errors on change with the given delay in milliseconds. + */ + "errorUpdate"?: boolean | number; + /** + * The validation errors for this input. Will render a hint under the input with the translated error message(s) `error.${key}`. If an object is passed, the keys will be used as error keys and the values translation parameters. If the value is `true`, the input will be marked as invalid without any hints under the input. + */ + "errors"?: boolean | string[] | ErrorMap; + /** + * Optional hint text(s) to be displayed with the select. + */ + "hint"?: string | string[]; + /** + * A unique identifier for the input. + */ + "identifier"?: string; + /** + * The label for the select. + */ + "label"?: string; + /** + * Visually hide the label, but still show it to assistive technologies like screen readers. + */ + "labelHidden"?: boolean; + /** + * The name of the form control. Submitted with the form as part of a name/value pair. + */ + "name"?: string; + /** + * Attributes that will be added to the native HTML input element. + */ + "nativeAttributes"?: { [key: string]: string }; + /** + * Emitted when the input loses focus. + */ + "onCatBlur"?: (event: CatTagCustomEvent) => void; + /** + * Emitted when the value is changed. + */ + "onCatChange"?: (event: CatTagCustomEvent) => void; + /** + * Emitted when the input received focus. + */ + "onCatFocus"?: (event: CatTagCustomEvent) => void; + /** + * The placeholder text to display within the select. + */ + "placeholder"?: string; + /** + * A value is required or must be checked for the form to be submittable. + */ + "required"?: boolean; + /** + * Whether the label need a marker to shown if the select is required or optional. + */ + "requiredMarker"?: 'none' | 'required' | 'optional' | 'none!' | 'optional!' | 'required!'; + /** + * List of characters that should create a new tag. This need to be comparable to `keydownEvent.key`. Pasted values will also be split by those chars. Defaults to `[' ']`. + */ + "tagCreationChars"?: string[]; + /** + * The value of the control. + */ + "value"?: string[]; + } /** * Textarea specifies a control that allows user to write text over multiple * rows. Used when the expected user input is long. For shorter input, use the @@ -3811,6 +3971,7 @@ declare namespace LocalJSX { "cat-spinner": CatSpinner; "cat-tab": CatTab; "cat-tabs": CatTabs; + "cat-tag": CatTag; "cat-textarea": CatTextarea; "cat-time": CatTime; "cat-toggle": CatToggle; @@ -3930,6 +4091,7 @@ declare module "@stencil/core" { * window, using tabs as a navigational element. */ "cat-tabs": LocalJSX.CatTabs & JSXBase.HTMLAttributes; + "cat-tag": LocalJSX.CatTag & JSXBase.HTMLAttributes; /** * Textarea specifies a control that allows user to write text over multiple * rows. Used when the expected user input is long. For shorter input, use the diff --git a/core/src/components/cat-button/readme.md b/core/src/components/cat-button/readme.md index 223caaf01..5ee075282 100644 --- a/core/src/components/cat-button/readme.md +++ b/core/src/components/cat-button/readme.md @@ -107,6 +107,7 @@ Type: `Promise` - [cat-select](../cat-select) - [cat-select-demo](../cat-select-demo) - [cat-tabs](../cat-tabs) + - [cat-tag](../cat-tag) - [cat-time](../cat-time) ### Depends on @@ -126,6 +127,7 @@ graph TD; cat-select --> cat-button cat-select-demo --> cat-button cat-tabs --> cat-button + cat-tag --> cat-button cat-time --> cat-button style cat-button fill:#f9f,stroke:#333,stroke-width:4px ``` diff --git a/core/src/components/cat-icon/readme.md b/core/src/components/cat-icon/readme.md index f3f949b64..2803987e5 100644 --- a/core/src/components/cat-icon/readme.md +++ b/core/src/components/cat-icon/readme.md @@ -41,6 +41,7 @@ doesn't fit. - [cat-button](../cat-button) - [cat-input](../cat-input) - [cat-select](../cat-select) + - [cat-tag](../cat-tag) - [cat-textarea](../cat-textarea) ### Graph @@ -51,6 +52,7 @@ graph TD; cat-button --> cat-icon cat-input --> cat-icon cat-select --> cat-icon + cat-tag --> cat-icon cat-textarea --> cat-icon style cat-icon fill:#f9f,stroke:#333,stroke-width:4px ``` diff --git a/core/src/components/cat-tag/cat-tag.e2e.ts b/core/src/components/cat-tag/cat-tag.e2e.ts new file mode 100644 index 000000000..2aab35b5b --- /dev/null +++ b/core/src/components/cat-tag/cat-tag.e2e.ts @@ -0,0 +1,16 @@ +import { newE2EPage } from '@stencil/core/testing'; + +describe('cat-tag', () => { + beforeAll(() => { + console.error = jest.fn(); + console.warn = jest.fn(); + }); + + it('renders', async () => { + const page = await newE2EPage(); + await page.setContent(''); + + const element = await page.find('cat-tag'); + expect(element).toHaveClass('hydrated'); + }); +}); diff --git a/core/src/components/cat-tag/cat-tag.scss b/core/src/components/cat-tag/cat-tag.scss new file mode 100644 index 000000000..5e05c176a --- /dev/null +++ b/core/src/components/cat-tag/cat-tag.scss @@ -0,0 +1,123 @@ +@use '_snippets/form-label'; +@use 'variables' as *; +@use 'src/components/cat-form-hint/cat-form-hint'; +@use 'utils/color'; +@use 'mixins' as *; + +:host { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +input { + font: inherit; + margin: 0; + min-width: 20rem; + padding: 0.375rem 0; + flex: 1 1 auto; + border: none; + outline: none; + background: none; + @include cat-ellipsis; + + .input-disabled & { + cursor: not-allowed; + color: cat-token('color.ui.font.muted'); + } + + &::placeholder { + color: cat-token('color.ui.font.muted'); + } + + /* stylelint-disable property-no-vendor-prefix */ + &:-webkit-autofill { + &, + &:hover, + &:focus { + -webkit-box-shadow: 0 0 0 9999px cat-token('color.ui.background.inputAutofill') inset; + } + } + /* stylelint-enable property-no-vendor-prefix */ +} + +.input-wrapper { + flex: 1 1 auto; + display: flex; + align-items: stretch; + gap: 0.25rem; + padding: 0.25rem 0.75rem; + min-height: 2rem; + background: cat-token('color.ui.background.input'); + border-radius: cat-border-radius('m'); + box-shadow: inset 0 0 0 1px rgb(var(--border-color)); + transition: box-shadow cat-token('time.transition.s') linear; + --border-color: #{cat-token('color.ui.border.dark', $wrap: false)}; + flex-wrap: wrap; + + &.input-disabled { + background: cat-token('color.ui.background.muted'); + cursor: not-allowed; + color: cat-token('color.ui.font.muted'); + } + + &:not(.input-disabled):hover { + box-shadow: + inset 0 0 0 1px rgb(var(--border-color)), + 0 0 0 1px rgb(var(--border-color)); + } + + &:focus-within { + outline: 2px solid cat-token('color.ui.border.focus'); + outline-offset: -1px; + + &:has(.clearable:focus) { + outline: none; + } + } + + &.input-invalid { + --border-color: #{cat-token('color.theme.danger.bg', 0.2, $wrap: false)}; + } + + /* stylelint-disable property-no-vendor-prefix */ + &:has(input:-webkit-autofill) { + &, + &:hover, + &:focus { + background-color: cat-token('color.ui.background.inputAutofill'); + } + } + /* stylelint-enable property-no-vendor-prefix */ +} + +.tag-pill { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.25rem 0.5rem; + background: cat-token('color.ui.background.muted'); + border-radius: cat-border-radius('s'); + white-space: nowrap; + min-width: 0; + + > span { + overflow: hidden; + text-overflow: ellipsis; + flex: 1 1 0; + } + + > cat-button { + margin-right: -0.25rem; + margin-left: -0.25rem; + } +} + +.icon-suffix { + align-self: center; +} + +.input-inner-wrapper { + flex: 1 1 auto; + display: flex; +} diff --git a/core/src/components/cat-tag/cat-tag.spec.tsx b/core/src/components/cat-tag/cat-tag.spec.tsx new file mode 100644 index 000000000..fb8856ecc --- /dev/null +++ b/core/src/components/cat-tag/cat-tag.spec.tsx @@ -0,0 +1,23 @@ +import { newSpecPage } from '@stencil/core/testing'; +import { CatTag } from './cat-tag'; + +describe('cat-tag', () => { + it('renders', async () => { + const page = await newSpecPage({ + components: [CatTag], + html: `` + }); + expect(page.root).toEqualHtml(` + + +
+
+
+ +
+
+
+
+ `); + }); +}); diff --git a/core/src/components/cat-tag/cat-tag.tsx b/core/src/components/cat-tag/cat-tag.tsx new file mode 100644 index 000000000..8aec7c3af --- /dev/null +++ b/core/src/components/cat-tag/cat-tag.tsx @@ -0,0 +1,324 @@ +import { Component, Host, h, State, Prop, Event, EventEmitter, Listen, Element, Watch } from '@stencil/core'; +import { coerceBoolean, coerceNumber } from '../../utils/coerce'; +import { CatFormHint, ErrorMap } from '../cat-form-hint/cat-form-hint'; +import { catI18nRegistry as i18n } from '../cat-i18n/cat-i18n-registry'; + +let nextUniqueId = 0; + +@Component({ + tag: 'cat-tag', + styleUrl: 'cat-tag.scss', + shadow: true +}) +export class CatTag { + private readonly _id = `cat-input-${nextUniqueId++}`; + + private input?: HTMLInputElement; + private errorMapSrc?: ErrorMap; + + private get id() { + return this.identifier || this._id; + } + + private get invalid() { + return !!Object.keys(this.errorMap || {}).length; + } + + private get hasHint() { + return !!this.hint || this.invalid; + } + + @Element() hostElement!: HTMLElement; + + @State() hasSlottedLabel = false; + @State() hasSlottedHint = false; + + @State() tags: string[] = []; + + @State() errorMap?: ErrorMap; + + /** + * Whether the label need a marker to shown if the select is required or optional. + */ + @Prop() requiredMarker?: 'none' | 'required' | 'optional' | 'none!' | 'optional!' | 'required!' = 'optional'; + + /** + * Whether the select is disabled. + */ + @Prop() disabled = false; + + /** + * The placeholder text to display within the select. + */ + @Prop() placeholder?: string; + + /** + * Optional hint text(s) to be displayed with the select. + */ + @Prop() hint?: string | string[]; + + /** + * A unique identifier for the input. + */ + @Prop() identifier?: string; + + /** + * The label for the select. + */ + @Prop() label = ''; + + /** + * The name of the form control. Submitted with the form as part of a name/value pair. + */ + @Prop() name?: string; + + /** + * Visually hide the label, but still show it to assistive technologies like screen readers. + */ + @Prop() labelHidden = false; + + /** + * A value is required or must be checked for the form to be submittable. + */ + @Prop() required = false; + + /** + * Attributes that will be added to the native HTML input element. + */ + @Prop() nativeAttributes?: { [key: string]: string }; + + /** + * The value of the control. + */ + @Prop({ mutable: true }) value?: string[]; + + /** + * Whether the input should show a clear button. + */ + @Prop() clearable = false; + + /** + * The validation errors for this input. Will render a hint under the input + * with the translated error message(s) `error.${key}`. If an object is + * passed, the keys will be used as error keys and the values translation + * parameters. + * If the value is `true`, the input will be marked as invalid without any + * hints under the input. + */ + @Prop() errors?: boolean | string[] | ErrorMap; + + /** + * Fine-grained control over when the errors are shown. Can be `false` to + * never show errors, `true` to show errors on blur, or a number to show + * errors on change with the given delay in milliseconds. + */ + @Prop() errorUpdate: boolean | number = 0; + + /** + * List of characters that should create a new tag. This need to be comparable to `keydownEvent.key`. + * Pasted values will also be split by those chars. + * Defaults to `[' ']`. + */ + @Prop() tagCreationChars: string[] = [' ']; + + /** + * Emitted when the value is changed. + */ + @Event() catChange!: EventEmitter; + + /** + * Emitted when the input received focus. + */ + @Event() catFocus!: EventEmitter; + + /** + * Emitted when the input loses focus. + */ + @Event() catBlur!: EventEmitter; + + @Listen('keydown') + onKeyDown(event: KeyboardEvent): void { + const isInputFocused = this.hostElement.shadowRoot?.activeElement === this.input; + if (['Enter', ...this.tagCreationChars].includes(event.key) && isInputFocused) { + event.preventDefault(); + if (this.input?.value.trim() && !this.value?.includes(this.input?.value.trim())) { + this.value = [...(this.value ?? []), this.input.value.trim()]; + this.catChange.emit(this.value); + } + if (this.input) { + this.input.value = ''; + } + } else if ( + ['Backspace'].includes(event.key) && + this.input?.selectionStart === 0 && + (this.value?.length ?? 0) > 0 && + isInputFocused + ) { + this.value = this.value?.slice(0, -1) ?? []; + this.catChange.emit(this.value); + } + } + + @Watch('errors') + onErrorsChanged(value?: boolean | string[] | ErrorMap) { + if (!coerceBoolean(this.errorUpdate)) { + this.errorMap = undefined; + } else { + this.errorMapSrc = Array.isArray(value) + ? (value as string[]).reduce((acc, err) => ({ ...acc, [err]: undefined }), {}) + : value === true + ? {} + : value || undefined; + this.showErrorsIfTimeout() || this.showErrorsIfNoFocus(); + } + } + + componentWillRender(): void { + this.onErrorsChanged(this.errors); + this.hasSlottedLabel = !!this.hostElement.querySelector('[slot="label"]'); + this.hasSlottedHint = !!this.hostElement.querySelector('[slot="hint"]'); + } + + render() { + return ( + +
+ {(this.hasSlottedLabel || this.label) && ( + + )} +
+
+ {this.value?.map(value => ( +
+ {value} + {!this.disabled && ( + this.deselect(value)} + tabIndex={-1} + > + )} +
+ ))} +
+ (this.input = el)} + aria-invalid={this.invalid ? 'true' : undefined} + aria-describedby={this.hasHint ? this.id + '-hint' : undefined} + onInput={this.onInput.bind(this)} + placeholder={this.placeholder} + disabled={this.disabled} + > + {this.clearable && !this.disabled && (this.value?.length ?? 0) > 0 && ( + + )} + {this.invalid && } +
+
+ {this.hasHint && ( + } + errorMap={this.errorMap} + /> + )} +
+ ); + } + + private onInput() { + const currentValue = [ + ...new Set(this.input?.value?.split(this.createSplitRegex(this.tagCreationChars)) ?? []) + ].filter(value => !!value && !this.value?.includes(value)); + if (currentValue.length > 1) { + this.value = [...(this.value ?? []), ...currentValue]; + this.catChange.emit(this.value); + if (this.input) { + this.input.value = ''; + } + } + } + + private clear() { + this.value = []; + this.catChange.emit(this.value); + if (this.input) { + this.input.value = ''; + } + } + + private deselect(value: string) { + this.value = this.value?.filter(element => element !== value); + } + + private showErrors() { + this.errorMap = this.errorMapSrc; + } + + private errorUpdateTimeoutId?: number; + private showErrorsIfTimeout() { + const errorUpdate = coerceNumber(this.errorUpdate, null); + if (errorUpdate !== null) { + typeof this.errorUpdateTimeoutId === 'number' && window.clearTimeout(this.errorUpdateTimeoutId); + this.errorUpdateTimeoutId = window.setTimeout(() => this.showErrors(), errorUpdate); + return true; + } + return false; + } + + private showErrorsIfNoFocus() { + const hasFocus = document.activeElement === this.hostElement || document.activeElement === this.input; + if (!hasFocus) { + this.showErrors(); + } + } + + private createSplitRegex(delimiters: string[]): RegExp { + // Escape special regex characters in the array + const escapedDelimiters = delimiters.map(delimiter => delimiter.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); + + // Add newline characters to the list of delimiters + escapedDelimiters.push('\\n', '\\r'); + + // Join the escaped delimiters to create a character class + const regexPattern = `[${escapedDelimiters.join('')}]`; + + // Return a new RegExp object with the global flag for splitting + return new RegExp(regexPattern, 'g'); + } +} diff --git a/core/src/components/cat-tag/readme.md b/core/src/components/cat-tag/readme.md new file mode 100644 index 000000000..fb5c36caf --- /dev/null +++ b/core/src/components/cat-tag/readme.md @@ -0,0 +1,65 @@ +# cat-tag + + + + + + +## Properties + +| Property | Attribute | Description | Type | Default | +| ------------------ | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | ------------ | +| `clearable` | `clearable` | Whether the input should show a clear button. | `boolean` | `false` | +| `disabled` | `disabled` | Whether the select is disabled. | `boolean` | `false` | +| `errorUpdate` | `error-update` | Fine-grained control over when the errors are shown. Can be `false` to never show errors, `true` to show errors on blur, or a number to show errors on change with the given delay in milliseconds. | `boolean \| number` | `0` | +| `errors` | `errors` | The validation errors for this input. Will render a hint under the input with the translated error message(s) `error.${key}`. If an object is passed, the keys will be used as error keys and the values translation parameters. If the value is `true`, the input will be marked as invalid without any hints under the input. | `boolean \| string[] \| undefined \| { [key: string]: any; }` | `undefined` | +| `hint` | `hint` | Optional hint text(s) to be displayed with the select. | `string \| string[] \| undefined` | `undefined` | +| `identifier` | `identifier` | A unique identifier for the input. | `string \| undefined` | `undefined` | +| `label` | `label` | The label for the select. | `string` | `''` | +| `labelHidden` | `label-hidden` | Visually hide the label, but still show it to assistive technologies like screen readers. | `boolean` | `false` | +| `name` | `name` | The name of the form control. Submitted with the form as part of a name/value pair. | `string \| undefined` | `undefined` | +| `nativeAttributes` | -- | Attributes that will be added to the native HTML input element. | `undefined \| { [key: string]: string; }` | `undefined` | +| `placeholder` | `placeholder` | The placeholder text to display within the select. | `string \| undefined` | `undefined` | +| `required` | `required` | A value is required or must be checked for the form to be submittable. | `boolean` | `false` | +| `requiredMarker` | `required-marker` | Whether the label need a marker to shown if the select is required or optional. | `"none!" \| "none" \| "optional!" \| "optional" \| "required!" \| "required" \| undefined` | `'optional'` | +| `tagCreationChars` | -- | List of characters that should create a new tag. This need to be comparable to `keydownEvent.key`. Pasted values will also be split by those chars. Defaults to `[' ']`. | `string[]` | `[' ']` | +| `value` | -- | The value of the control. | `string[] \| undefined` | `undefined` | + + +## Events + +| Event | Description | Type | +| ----------- | -------------------------------------- | ------------------------- | +| `catBlur` | Emitted when the input loses focus. | `CustomEvent` | +| `catChange` | Emitted when the value is changed. | `CustomEvent` | +| `catFocus` | Emitted when the input received focus. | `CustomEvent` | + + +## Shadow Parts + +| Part | Description | +| --------- | ----------- | +| `"input"` | | +| `"label"` | | + + +## Dependencies + +### Depends on + +- [cat-button](../cat-button) +- [cat-icon](../cat-icon) + +### Graph +```mermaid +graph TD; + cat-tag --> cat-button + cat-tag --> cat-icon + cat-button --> cat-icon + cat-button --> cat-spinner + style cat-tag fill:#f9f,stroke:#333,stroke-width:4px +``` + +---------------------------------------------- + +Made with love in Hamburg, Germany diff --git a/core/src/index.html b/core/src/index.html index be9d9f4b1..d03322354 100644 --- a/core/src/index.html +++ b/core/src/index.html @@ -251,6 +251,7 @@

Catalyst Web Components

  • Skeleton
  • Spinner
  • Tabs
  • +
  • Tags
  • Textarea
  • Toggle
  • Tooltip
  • @@ -1115,6 +1116,11 @@

    Alignment

    +
    + + +
    +

    Textarea

    diff --git a/core/stencil.config.ts b/core/stencil.config.ts index 553b90208..086d937df 100644 --- a/core/stencil.config.ts +++ b/core/stencil.config.ts @@ -36,7 +36,7 @@ const angularValueAccessorBindings: ValueAccessorConfig[] = [ type: 'radio' }, { - elementSelectors: ['cat-select'], + elementSelectors: ['cat-select', 'cat-tag'], event: 'catChange', targetAttr: 'value', type: 'select' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 85134333e..eed3c75df 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -109,45 +109,6 @@ importers: specifier: ~4.6.3 version: 4.6.4 - angular/dist/catalyst: - dependencies: - '@angular/cdk': - specifier: '>=14.0.0' - version: 14.2.7(@angular/common@14.3.0(@angular/core@14.3.0(rxjs@7.8.1)(zone.js@0.11.8))(rxjs@7.8.1))(@angular/core@14.3.0(rxjs@7.8.1)(zone.js@0.11.8))(rxjs@7.8.1) - '@angular/core': - specifier: '>=14.0.0' - version: 14.3.0(rxjs@7.8.1)(zone.js@0.11.8) - '@haiilo/catalyst': - specifier: workspace:* - version: link:../../../core - '@haiilo/catalyst-tokens': - specifier: workspace:* - version: link:../../../tokens - loglevel: - specifier: 1.8.1 - version: 1.8.1 - rxjs: - specifier: ^6.5.3 || ^7.4.0 - version: 7.8.1 - tslib: - specifier: ^2.3.0 - version: 2.6.2 - - angular/dist/catalyst-formly: - dependencies: - '@angular/core': - specifier: ^14.0.0 || ^15.0.0 || ^16.0.0 - version: 14.3.0(rxjs@7.8.1)(zone.js@0.11.8) - '@haiilo/catalyst-angular': - specifier: workspace:* - version: link:../../projects/catalyst - '@ngx-formly/core': - specifier: ^6.0.0 - version: 6.3.0(@angular/forms@14.3.0(@angular/common@14.3.0(@angular/core@14.3.0(rxjs@7.8.1)(zone.js@0.11.8))(rxjs@7.8.1))(@angular/core@14.3.0(rxjs@7.8.1)(zone.js@0.11.8))(@angular/platform-browser@14.3.0(@angular/animations@14.3.0(@angular/core@14.3.0(rxjs@7.8.1)(zone.js@0.11.8)))(@angular/common@14.3.0(@angular/core@14.3.0(rxjs@7.8.1)(zone.js@0.11.8))(rxjs@7.8.1))(@angular/core@14.3.0(rxjs@7.8.1)(zone.js@0.11.8)))(rxjs@7.8.1))(rxjs@7.8.1) - tslib: - specifier: ^2.3.0 - version: 2.6.2 - angular/projects/catalyst: dependencies: '@angular/cdk': diff --git a/react/src/components/stencil-generated/index.ts b/react/src/components/stencil-generated/index.ts index 24ec328f0..da40b7fc2 100644 --- a/react/src/components/stencil-generated/index.ts +++ b/react/src/components/stencil-generated/index.ts @@ -32,6 +32,7 @@ export const CatSkeleton = /*@__PURE__*/createReactComponent('cat-spinner'); export const CatTab = /*@__PURE__*/createReactComponent('cat-tab'); export const CatTabs = /*@__PURE__*/createReactComponent('cat-tabs'); +export const CatTag = /*@__PURE__*/createReactComponent('cat-tag'); export const CatTextarea = /*@__PURE__*/createReactComponent('cat-textarea'); export const CatTime = /*@__PURE__*/createReactComponent('cat-time'); export const CatToggle = /*@__PURE__*/createReactComponent('cat-toggle'); diff --git a/vue/src/components.ts b/vue/src/components.ts index ae6b8b038..c48c9396c 100644 --- a/vue/src/components.ts +++ b/vue/src/components.ts @@ -387,6 +387,28 @@ export const CatTabs = /*@__PURE__*/ defineContainer('cat-tabs', un ]); +export const CatTag = /*@__PURE__*/ defineContainer('cat-tag', undefined, [ + 'requiredMarker', + 'disabled', + 'placeholder', + 'hint', + 'identifier', + 'label', + 'name', + 'labelHidden', + 'required', + 'nativeAttributes', + 'value', + 'clearable', + 'errors', + 'errorUpdate', + 'tagCreationChars', + 'catChange', + 'catFocus', + 'catBlur' +]); + + export const CatTextarea = /*@__PURE__*/ defineContainer('cat-textarea', undefined, [ 'requiredMarker', 'horizontal',