diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index e426c11d7..74b9f737a 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -34,6 +34,7 @@ /packages/components/timezone/* @NikGurev /packages/components/toggle/* @NikGurev /packages/components/top-bar/* @NikGurev +/packages/components/username/* @NikGurev /packages/components/actions-panel/* @artembelik /packages/components/autocomplete/* @artembelik diff --git a/angular.json b/angular.json index 37cf7687f..9a09a8a0e 100644 --- a/angular.json +++ b/angular.json @@ -2368,6 +2368,36 @@ } } }, + "dev-username": { + "projectType": "application", + "root": "packages/components-dev/username", + "sourceRoot": "packages/components-dev/username", + "architect": { + "build": { + "builder": "@angular-devkit/build-angular:application", + "options": { + "outputPath": { + "base": "dist/components-dev/username" + }, + "tsConfig": "packages/components-dev/username/tsconfig.app.json", + "index": "packages/components-dev/index.html", + "styles": ["packages/components-dev/main.scss"], + "polyfills": ["zone.js"], + "extractLicenses": false, + "sourceMap": true, + "optimization": false, + "namedChunks": true, + "browser": "packages/components-dev/username/main.ts" + } + }, + "serve": { + "builder": "@angular-devkit/build-angular:dev-server", + "options": { + "buildTarget": "dev-username:build" + } + } + } + }, "dev-validation": { "projectType": "application", "root": "packages/components-dev/validation", diff --git a/apps/docs/src/app/services/documentation-items.ts b/apps/docs/src/app/services/documentation-items.ts index edaffe014..436c3796e 100644 --- a/apps/docs/src/app/services/documentation-items.ts +++ b/apps/docs/src/app/services/documentation-items.ts @@ -793,6 +793,17 @@ const DOCS: { [key: string]: DocsDocCategory[] } = { hasApi: true, apiId: 'tree-select', hasExamples: true + }, + { + id: 'username', + name: { + ru: 'Username', + en: 'Username' + }, + hasApi: true, + apiId: 'username', + hasExamples: false, + isNew: expiresAt('2025-08-24') } ] } diff --git a/apps/docs/src/sitemap.xml b/apps/docs/src/sitemap.xml index 581d49856..baba59d49 100644 --- a/apps/docs/src/sitemap.xml +++ b/apps/docs/src/sitemap.xml @@ -720,6 +720,18 @@ https://koobiq.io/ru/components/tree-select/api + + https://koobiq.io/en/components/username/overview + + + https://koobiq.io/ru/components/username/overview + + + https://koobiq.io/en/components/username/api + + + https://koobiq.io/ru/components/username/api + https://koobiq.io/en/other/date-formatter/overview diff --git a/package.json b/package.json index e9925d44d..5e46c5a99 100644 --- a/package.json +++ b/package.json @@ -264,6 +264,7 @@ "dev:tree": "ng serve dev-tree --port 3003", "dev:tree-select": "ng serve dev-tree-select --port 3003", "dev:typography": "ng serve dev-typography --port 3003", + "dev:username": "ng serve dev-username --port 3003", "dev:validation": "ng serve dev-validation --port 3003", "dev:z-index": "ng serve dev-z-index --port 3003", "-----UNIT_TESTS-----": "-------------------------------------------------------------------------------------", diff --git a/packages/components-dev/username/main.ts b/packages/components-dev/username/main.ts new file mode 100644 index 000000000..2bbc80baf --- /dev/null +++ b/packages/components-dev/username/main.ts @@ -0,0 +1,9 @@ +import { bootstrapApplication } from '@angular/platform-browser'; +import { provideAnimations } from '@angular/platform-browser/animations'; +import { DevApp } from './module'; + +bootstrapApplication(DevApp, { + providers: [ + provideAnimations() + ] +}).catch((error) => console.error(error)); diff --git a/packages/components-dev/username/module.ts b/packages/components-dev/username/module.ts new file mode 100644 index 000000000..39a93dd26 --- /dev/null +++ b/packages/components-dev/username/module.ts @@ -0,0 +1,40 @@ +import { ChangeDetectionStrategy, Component, ViewEncapsulation } from '@angular/core'; +import { UsernameExamplesModule } from '../../docs-examples/components/username'; + +@Component({ + selector: 'dev-examples', + standalone: true, + imports: [UsernameExamplesModule], + template: ` + + + + + `, + styles: ` + :host { + display: flex; + gap: var(--kbq-size-l); + flex-wrap: wrap; + } + :host > * { + border-radius: var(--kbq-size-border-radius); + border: 1px solid var(--kbq-line-contrast-less); + margin-bottom: var(--kbq-size-l); + padding: var(--kbq-size-m); + flex: 1 0 auto; + } + `, + changeDetection: ChangeDetectionStrategy.OnPush +}) +class DevExamples {} + +@Component({ + selector: 'dev-app', + standalone: true, + imports: [DevExamples], + templateUrl: './template.html', + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class DevApp {} diff --git a/packages/components-dev/username/template.html b/packages/components-dev/username/template.html new file mode 100644 index 000000000..c6a5b7b43 --- /dev/null +++ b/packages/components-dev/username/template.html @@ -0,0 +1 @@ + diff --git a/packages/components-dev/username/tsconfig.app.json b/packages/components-dev/username/tsconfig.app.json new file mode 100644 index 000000000..81fb90e7c --- /dev/null +++ b/packages/components-dev/username/tsconfig.app.json @@ -0,0 +1,5 @@ +{ + "extends": "../tsconfig.json", + "include": ["**/*.d.ts"], + "files": ["main.ts"] +} diff --git a/packages/components/username/__snapshots__/username.spec.ts.snap b/packages/components/username/__snapshots__/username.spec.ts.snap new file mode 100644 index 000000000..b152498cf --- /dev/null +++ b/packages/components/username/__snapshots__/username.spec.ts.snap @@ -0,0 +1,20 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`KbqUsername should use default input values 1`] = ` +{ + "kbq-username": true, + "kbq-username_default": true, + "kbq-username_inline": true, +} +`; + +exports[`KbqUsernamePipe should format full name using custom format 1`] = ` +[ + "Alice B C", + "Alice B. C.", + "AliceB.C.", + "A B C", + "A.B.C.", + "Alice B. C.", +] +`; diff --git a/packages/components/username/constants.ts b/packages/components/username/constants.ts new file mode 100644 index 000000000..ab7f7df8d --- /dev/null +++ b/packages/components/username/constants.ts @@ -0,0 +1,30 @@ +import { InjectionToken } from '@angular/core'; +import { KbqFormatKeyToProfileMapping, KbqFormatKeyToProfileMappingExtended, KbqUsernameFormatKey } from './types'; + +/** Default name format: Last name full, first and middle as initials. */ +export const kbqDefaultFullNameFormatCustom = 'L f. m.'; +/** Default name format: Last name full, first and middle as initials. */ +export const kbqDefaultFullNameFormat = 'lf.m.'; + +/** + * Throws an error when no profile field mapping is provided to the username pipe. + * @docs-private + */ +export function KbqMappingMissingError() { + return new Error('KbqUsernamePipe: profile field mapping is required but was not provided.'); +} + +/** + * Injection token for providing a global username format-to-profile mapping. + */ +export const KBQ_PROFILE_MAPPING = new InjectionToken< + KbqFormatKeyToProfileMapping | KbqFormatKeyToProfileMappingExtended +>('KBQ_PROFILE_MAPPING', { + factory: () => + ({ + [KbqUsernameFormatKey.FirstNameShort]: 'firstName', + [KbqUsernameFormatKey.MiddleNameShort]: 'middleName', + [KbqUsernameFormatKey.LastNameShort]: 'lastName', + [KbqUsernameFormatKey.Dot]: undefined + }) satisfies KbqFormatKeyToProfileMapping<{ firstName: string; middleName: string; lastName: string }> +}); diff --git a/packages/components/username/index.ts b/packages/components/username/index.ts new file mode 100644 index 000000000..245d33d7e --- /dev/null +++ b/packages/components/username/index.ts @@ -0,0 +1,5 @@ +export * from './constants'; +export * from './module'; +export * from './types'; +export * from './username'; +export * from './username.pipe'; diff --git a/packages/components/username/module.ts b/packages/components/username/module.ts new file mode 100644 index 000000000..5a5693fcc --- /dev/null +++ b/packages/components/username/module.ts @@ -0,0 +1,18 @@ +import { NgModule } from '@angular/core'; +import { KbqUsername, KbqUsernameCustomView, KbqUsernamePrimary, KbqUsernameSecondary } from './username'; +import { KbqUsernameCustomPipe, KbqUsernamePipe } from './username.pipe'; + +const COMPONENTS = [ + KbqUsername, + KbqUsernameCustomView, + KbqUsernamePrimary, + KbqUsernameSecondary, + KbqUsernameCustomPipe, + KbqUsernamePipe +]; + +@NgModule({ + imports: COMPONENTS, + exports: COMPONENTS +}) +export class KbqUsernameModule {} diff --git a/packages/components/username/ng-package.json b/packages/components/username/ng-package.json new file mode 100644 index 000000000..bebf62dcb --- /dev/null +++ b/packages/components/username/ng-package.json @@ -0,0 +1,5 @@ +{ + "lib": { + "entryFile": "index.ts" + } +} diff --git a/packages/components/username/types.ts b/packages/components/username/types.ts new file mode 100644 index 000000000..eec04a1a3 --- /dev/null +++ b/packages/components/username/types.ts @@ -0,0 +1,74 @@ +/** + * Keys for formatting username parts in short (initial) or full form. + */ +export enum KbqUsernameFormatKey { + /** + * Short form of the first name (e.g., "John" → "J") + */ + FirstNameShort = 'f', + + /** + * Full form of the first name (e.g., "John") + */ + FirstNameFull = 'F', + + /** + * Short form of the middle name (e.g., "Henry" → "H") + */ + MiddleNameShort = 'm', + + /** + * Full form of the middle name (e.g., "Henry") + */ + MiddleNameFull = 'M', + + /** + * Short form of the last name (e.g., "Doe" → "D") + */ + LastNameShort = 'l', + + /** + * Full form of the last name (e.g., "Doe") + */ + LastNameFull = 'L', + Dot = '.' +} + +/** + * Maps each format key to a property name in the user profile object. + * Allows flexible formatting regardless of profile field names. + */ +export type KbqFormatKeyToProfileMapping = { + [key in Exclude< + KbqUsernameFormatKey, + KbqUsernameFormatKey.FirstNameFull | KbqUsernameFormatKey.MiddleNameFull | KbqUsernameFormatKey.LastNameFull + >]: keyof T | undefined; +}; + +/** + * Maps each format key to a property name in the user profile object. + * Allows flexible formatting regardless of profile field names. + * @see KbqUsernameCustomPipe + */ +export type KbqFormatKeyToProfileMappingExtended = { + [key in KbqUsernameFormatKey]: keyof T | undefined; +}; + +/** + * Layout mode for displaying a username and applying text-ellipsis. + * + * - `stacked`: Elements shown vertically. + * - `inline`: Elements shown in one line. Text ellipsis is applied to both parts. + * - `text`: Plain text, no layout styling. No text-ellipsis. + */ +export type KbqUsernameMode = 'stacked' | 'inline' | 'text'; + +/** + * Visual style of the username. + * + * - `default`: standard styling with primary and secondary colors. + * - `error`: error colors (e.g., red). + * - `accented`: no color theming; emphasizes via typography only. + * - `inherit`: inherits parent styles, no theming. For example, useful when using inside links. + */ +export type KbqUsernameStyle = 'default' | 'error' | 'accented' | 'inherit'; diff --git a/packages/components/username/username-tokens.scss b/packages/components/username/username-tokens.scss new file mode 100644 index 000000000..1b8a972fe --- /dev/null +++ b/packages/components/username/username-tokens.scss @@ -0,0 +1,6 @@ +:where(.kbq-username) { + --kbq-username-primary-color: var(--kbq-foreground-contrast); + --kbq-username-secondary-color: inherit; + --kbq-username-vertical-gap: var(--kbq-size-xxs); + --kbq-username-horizontal-gap: var(--kbq-size-xxs); +} diff --git a/packages/components/username/username.en.md b/packages/components/username/username.en.md new file mode 100644 index 000000000..b7841f123 --- /dev/null +++ b/packages/components/username/username.en.md @@ -0,0 +1,23 @@ +The pattern is used when the interface refers to an internal user. + +Displaying the username means showing a set of attributes from the internal system user that help identify them in the interface. + + + +### Configuration demo + + + +### Custom template + +If flexible layout is required and the default template doesn’t meet your needs, use the `custom` mode with the `KbqUsernameCustomView` directive. + +This mode supports the same display styles and modes while maintaining consistency with the design system. + +To format the full name, use the `kbqUsernameCustom` pipe with a format string and a mapping definition (an object that links format elements to corresponding user properties, determining what data should be shown). + + + +The component can be conveniently used inside links. To visually match the link style, set the `inherit` style — this ensures that color and appearance are inherited from the parent element. + + diff --git a/packages/components/username/username.html b/packages/components/username/username.html new file mode 100644 index 000000000..d2a67337c --- /dev/null +++ b/packages/components/username/username.html @@ -0,0 +1,35 @@ +@let profile = userInfo(); + + +@if (!customView() && profile) { + @let fullName = profile | kbqUsername: fullNameFormat(); + + @if (!isCompact()) { + @if (hasFullName()) { + {{ fullName }} + } + + @if (profile.login) { + + {{ profile.login }} + + @if (profile.site) { + ({{ profile.site }}) + } + + } + } @else { + + @if (hasFullName()) { + {{ fullName }} + } + @if (!hasFullName() && profile.login) { + {{ profile.login }} + } + + @if (profile.site) { +  ({{ profile.site }}) + } + + } +} diff --git a/packages/components/username/username.pipe.ts b/packages/components/username/username.pipe.ts new file mode 100644 index 000000000..f460ad12a --- /dev/null +++ b/packages/components/username/username.pipe.ts @@ -0,0 +1,99 @@ +import { inject, Injectable, Pipe, PipeTransform } from '@angular/core'; +import { + KBQ_PROFILE_MAPPING, + kbqDefaultFullNameFormat, + kbqDefaultFullNameFormatCustom, + KbqMappingMissingError +} from './constants'; +import { KbqFormatKeyToProfileMapping, KbqFormatKeyToProfileMappingExtended, KbqUsernameFormatKey } from './types'; + +@Injectable({ providedIn: 'root' }) +@Pipe({ + name: 'kbqUsername', + pure: true, + standalone: true +}) +export class KbqUsernamePipe implements PipeTransform { + private readonly mapping = inject(KBQ_PROFILE_MAPPING, { optional: true }); + + /** Builds a formatted name string from the user profile using the provided format and mapping. */ + transform(profile: T, format = kbqDefaultFullNameFormat, customMapping?: KbqFormatKeyToProfileMapping): string { + const resolvedMapping = customMapping || this.mapping; + + if (!resolvedMapping) { + throw KbqMappingMissingError(); + } + + if (!profile || typeof profile !== 'object') return ''; + + let result = ''; + + const formatUnits = format.split(''); + + formatUnits.forEach((letter: KbqUsernameFormatKey | string, index: number, array) => { + if (letter !== KbqUsernameFormatKey.Dot) { + const field: keyof T = resolvedMapping[letter]; + const fieldValue = profile[field]; + + if (fieldValue) { + const isShort = array[index + 1] === KbqUsernameFormatKey.Dot; + const resolvedFieldValue = isShort ? `${fieldValue[0]}${KbqUsernameFormatKey.Dot}` : fieldValue; + + result += ` ${resolvedFieldValue}`; + } + } + }); + + return result.trim(); + } +} + +/** + * Pipe to format a user profile into a name string using a format pattern and field mapping. + * Lowercase keys output initials; uppercase keys show full values. + */ +@Injectable({ providedIn: 'root' }) +@Pipe({ + name: 'kbqUsernameCustom', + pure: true, + standalone: true +}) +export class KbqUsernameCustomPipe implements PipeTransform { + private readonly mapping = inject(KBQ_PROFILE_MAPPING, { optional: true }); + + /** Builds a formatted name string from the user profile using the provided format and mapping. */ + transform( + profile: T, + format = kbqDefaultFullNameFormatCustom, + customMapping?: KbqFormatKeyToProfileMappingExtended + ): string { + const resolvedMapping = customMapping || this.mapping; + + if (!resolvedMapping) { + throw KbqMappingMissingError(); + } + + if (!profile || typeof profile !== 'object') return ''; + + let result = ''; + + const formatUnits = format.split(''); + + formatUnits.forEach((letter: KbqUsernameFormatKey | string) => { + const field: keyof T | undefined = resolvedMapping[letter]; + + if (!field) { + result += letter; + + return; + } + + const isShort = letter === letter.toLowerCase(); + const fieldValue = profile[field] || ''; + + result += fieldValue && isShort ? fieldValue[0] : fieldValue; + }); + + return result.trim(); + } +} diff --git a/packages/components/username/username.ru.md b/packages/components/username/username.ru.md new file mode 100644 index 000000000..f38196d4e --- /dev/null +++ b/packages/components/username/username.ru.md @@ -0,0 +1,19 @@ +Компонент отображает информацию о пользователе в едином стиле. Надпись формируется на основе данных профиля с учётом выбранного режима отображения и настройки компактности. + + + +### Настройки и режимы + + + +### Пользовательский шаблон + +Если необходимо гибкое отображение данных и стандартный шаблон не устраивает, то используйте режим `custom` с помощью директивы `KbqUsernameCustomView`. В нем поддерживаются те же режимы и стили, при этом сохраняется согласованность с дизайн-системой. + +Для форматирования полного имени можно использовать пайп `kbqUsernameCustom` с настройками формата и определением соответствия (объекта, который связывает элементы формата имени с соответствующими свойствами пользователя, определяя, какие данные подставлять при отображении). + + + +Компонент удобно использовать внутри ссылок. Чтобы он визуально соответствовал стилю ссылки, установите стиль `inherit` — в этом случае цвет и оформление будут унаследованы от родительского элемента. + + diff --git a/packages/components/username/username.scss b/packages/components/username/username.scss new file mode 100644 index 000000000..c9126c117 --- /dev/null +++ b/packages/components/username/username.scss @@ -0,0 +1,107 @@ +@use '../core/styles/common'; +@use '../core/styles/common/tokens'; + +// Targets the layout container: either the root or custom view if present. +@mixin _kbq-username-content-wrapper { + &:not(:has(> .kbq-username__custom-view)), + & > .kbq-username__custom-view { + @content; + } +} + +@mixin _kbq-username-theme() { + & { + color: var(--kbq-username-primary-color); + + .kbq-username__secondary, + .kbq-username__secondary-hint { + color: var(--kbq-username-secondary-color); + } + } + + &.kbq-username_default { + .kbq-username__secondary-hint, + .kbq-username__primary + .kbq-username__secondary { + --kbq-username-secondary-color: var(--kbq-foreground-contrast-secondary); + } + } + + &.kbq-username_error { + --kbq-username-primary-color: var(--kbq-foreground-error); + --kbq-username-secondary-color: var(--kbq-foreground-error); + } + + &.kbq-username_accented { + --kbq-username-primary-color: var(--kbq-foreground-contrast); + --kbq-username-secondary-color: var(--kbq-foreground-contrast); + } + + &.kbq-username_inherit { + --kbq-username-primary-color: inherit; + --kbq-username-secondary-color: inherit; + } +} + +@mixin _kbq-username-typography() { + & { + @include tokens.kbq-typography-level-to-styles_css-variables(typography, text-normal); + } + + &.kbq-username_accented { + .kbq-username__primary { + @include tokens.kbq-typography-level-to-styles_css-variables(typography, text-normal-strong); + } + } +} + +.kbq-username { + @include _kbq-username-theme(); + @include _kbq-username-typography(); + + &.kbq-username_stacked { + @include _kbq-username-content-wrapper { + display: flex; + flex-flow: column nowrap; + justify-content: flex-start; + align-items: normal; + + gap: var(--kbq-username-vertical-gap); + } + } + + &.kbq-username_inline:not(.kbq-username_inherit) { + @include _kbq-username-content-wrapper { + display: flex; + flex-flow: row nowrap; + justify-content: flex-start; + align-items: baseline; + + .kbq-username__primary + .kbq-username__secondary { + @include common.rtl-prop(margin-left, margin-right, var(--kbq-username-horizontal-gap), 0); + } + } + } + + &.kbq-username_text { + @include _kbq-username-content-wrapper { + display: inline; + } + } + + &.kbq-username_stacked, + &.kbq-username_inline { + @include _kbq-username-content-wrapper { + .kbq-username__primary, + .kbq-username__secondary { + @include common.kbq-truncate-line(); + } + } + } + + &.kbq-username_inherit { + @include _kbq-username-content-wrapper { + font: inherit; + display: inherit; + } + } +} diff --git a/packages/components/username/username.spec.ts b/packages/components/username/username.spec.ts new file mode 100644 index 000000000..89978a24b --- /dev/null +++ b/packages/components/username/username.spec.ts @@ -0,0 +1,193 @@ +import { ChangeDetectionStrategy, Component, Type } from '@angular/core'; +import { ComponentFixture, inject, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { + KbqFormatKeyToProfileMapping, + KbqFormatKeyToProfileMappingExtended, + KbqUsernameFormatKey, + KbqUsernameMode, + KbqUsernameStyle +} from './types'; +import { KbqUsername, KbqUsernameCustomView } from './username'; +import { KbqUsernameCustomPipe, KbqUsernamePipe } from './username.pipe'; + +const createComponent = (component: Type, providers: any[] = []): ComponentFixture => { + TestBed.configureTestingModule({ imports: [component], providers }).compileComponents(); + const fixture = TestBed.createComponent(component); + + fixture.autoDetectChanges(); + + return fixture; +}; + +type ExampleUser = { + firstName?: string; + lastName?: string; + middleName?: string; +}; + +const mockProfile: ExampleUser = { + firstName: 'Alice', + middleName: 'Bishop', + lastName: 'Carter' +}; + +describe(KbqUsernamePipe.name, () => { + const mockMapping: KbqFormatKeyToProfileMapping = { + [KbqUsernameFormatKey.FirstNameShort]: 'firstName', + [KbqUsernameFormatKey.MiddleNameShort]: 'middleName', + [KbqUsernameFormatKey.LastNameShort]: 'lastName', + [KbqUsernameFormatKey.Dot]: undefined + }; + + let pipe: KbqUsernamePipe; + + beforeEach(inject([KbqUsernamePipe], (p: KbqUsernamePipe) => { + pipe = p; + })); + + it('should format full name using default format', () => { + const result = pipe.transform(mockProfile, undefined, mockMapping); + + expect(result).toBe('Carter A. B.'); + }); + + it('should return empty string for empty profile', () => { + const result = pipe.transform(null as any, 'FML', mockMapping); + + expect(result).toBe(''); + }); + + it('should return empty string if profile is not an object', () => { + const result = pipe.transform([], 'FML', mockMapping); + + expect(result).toBe(''); + }); + + it('should skip irrelevant letters in format', () => { + const irrelevantLetter = 's'; + const result = pipe.transform(mockProfile, `lf.m.${irrelevantLetter}`, mockMapping); + + expect(result.includes(irrelevantLetter)).toBeFalsy(); + }); +}); + +describe(KbqUsernamePipe.name, () => { + let pipe: KbqUsernameCustomPipe; + const mockMapping: KbqFormatKeyToProfileMappingExtended = { + [KbqUsernameFormatKey.FirstNameShort]: 'firstName', + [KbqUsernameFormatKey.FirstNameFull]: 'firstName', + [KbqUsernameFormatKey.MiddleNameShort]: 'middleName', + [KbqUsernameFormatKey.MiddleNameFull]: 'middleName', + [KbqUsernameFormatKey.LastNameShort]: 'lastName', + [KbqUsernameFormatKey.LastNameFull]: 'lastName', + [KbqUsernameFormatKey.Dot]: undefined + }; + + beforeEach(inject([KbqUsernameCustomPipe], (p: KbqUsernameCustomPipe) => { + pipe = p; + })); + + it('should format full name using default format', () => { + const result = pipe.transform(mockProfile, undefined, mockMapping); + + expect(result).toBe('Carter A. B.'); + }); + + it('should format full name using custom format', () => { + const formats = ['F m l', 'F m. l.', 'Fm.l.', 'f m l', 'f.m.l.', 'F\u2009m.\u2009l.']; + + expect(formats.map((format) => pipe.transform(mockProfile, format, mockMapping))).toMatchSnapshot(); + }); + + it('should return empty string for empty profile', () => { + const result = pipe.transform(null as any, 'FML', mockMapping); + + expect(result).toBe(''); + }); + + it('should return empty string if profile is not an object', () => { + const result = pipe.transform([], 'FML', mockMapping); + + expect(result).toBe(''); + }); + + it('should add irrelevant letters in format', () => { + const irrelevantLetter = 's'; + const result = pipe.transform(mockProfile, `lf.m.${irrelevantLetter}`, mockMapping); + + expect(result.includes(irrelevantLetter)).toBeTruthy(); + }); +}); + +describe(KbqUsername.name, () => { + it('should use default input values', () => { + const { debugElement } = createComponent(TestComponent); + + expect(debugElement.query(By.directive(KbqUsername)).classes).toMatchSnapshot(); + }); + + it('should use custom view instead default if provided', () => { + const fixture = createComponent(CustomView); + + fixture.detectChanges(); + + expect(fixture.debugElement.query(By.css('.kbq-username__primary'))).toBeFalsy(); + }); +}); + +@Component({ + selector: 'test-component', + standalone: true, + imports: [ + KbqUsername + ], + template: ` + + `, + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class TestComponent { + userInfo: any = { + firstName: 'firstName', + middleName: 'MiddleName', + lastName: 'LastName', + login: 'login' + }; + selectedMode: KbqUsernameMode = 'inline'; + selectedType: KbqUsernameStyle = 'default'; + isCompact = false; + fullNameFormat = 'f.m.l'; +} + +@Component({ + selector: 'test-component', + standalone: true, + imports: [ + KbqUsername, + KbqUsernameCustomView + ], + template: ` + + Test + + `, + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class CustomView { + userInfo: any = { + firstName: 'firstName', + middleName: 'MiddleName', + lastName: 'LastName', + login: 'login' + }; + selectedMode: KbqUsernameMode = 'inline'; + selectedType: KbqUsernameStyle = 'default'; + isCompact = false; +} diff --git a/packages/components/username/username.ts b/packages/components/username/username.ts new file mode 100644 index 000000000..fc3bfa0cc --- /dev/null +++ b/packages/components/username/username.ts @@ -0,0 +1,136 @@ +import { + booleanAttribute, + ChangeDetectionStrategy, + Component, + computed, + contentChild, + Directive, + input, + ViewEncapsulation +} from '@angular/core'; +import { KbqTitleModule } from '@koobiq/components/title'; +import { kbqDefaultFullNameFormat } from './constants'; +import { KbqUsernameMode, KbqUsernameStyle } from './types'; +import { KbqUsernamePipe } from './username.pipe'; + +const baseClass = 'kbq-username'; + +/** + * Basic user info + * @docs-private + */ +export type KbqUserInfo = { + firstName?: string; + lastName?: string; + middleName?: string; + login?: string; + site?: string; +}; + +/** Styles the primary part of the username (e.g. full name). */ +@Directive({ + selector: '[kbqUsernamePrimary]', + exportAs: 'kbqUsernamePrimary', + standalone: true, + host: { + class: `${baseClass}__primary` + } +}) +export class KbqUsernamePrimary {} + +/** Styles the secondary part. */ +@Directive({ + selector: '[kbqUsernameSecondary]', + exportAs: 'kbqUsernameSecondary', + standalone: true, + host: { + class: `${baseClass}__secondary` + } +}) +export class KbqUsernameSecondary {} + +/** Styles a secondary hint. */ +@Directive({ + selector: '[kbqUsernameSecondaryHint]', + exportAs: 'kbqUsernameSecondaryHint', + standalone: true, + host: { + class: `${baseClass}__secondary-hint` + } +}) +export class KbqUsernameSecondaryHint {} + +/** Custom content for `KbqUsername`, overrides default view. */ +@Directive({ + selector: 'kbq-username-custom-view, [kbq-username-custom-view]', + standalone: true, + exportAs: 'kbqUsernameCustomView', + host: { + class: `${baseClass}__custom-view` + } +}) +export class KbqUsernameCustomView {} + +/** + * Displays a user's name based on profile data. + * Supports different display modes and visual styles. + * A custom view can be provided via `` for full control over the output. + * Accepts input profile data and optional formatting options. + */ +@Component({ + selector: 'kbq-username', + standalone: true, + exportAs: 'kbqUsername', + imports: [ + KbqTitleModule, + KbqUsernamePipe, + KbqUsernamePrimary, + KbqUsernameSecondary, + KbqUsernameSecondaryHint + ], + templateUrl: './username.html', + styleUrls: ['./username.scss', './username-tokens.scss'], + host: { + class: baseClass, + '[class]': 'class()' + }, + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class KbqUsername { + /** User profile data used for display. */ + readonly userInfo = input(); + /** Enables compact display mode */ + readonly isCompact = input(false, { transform: booleanAttribute }); + /** Format string for rendering the user's full name. */ + readonly fullNameFormat = input(kbqDefaultFullNameFormat); + /** + * Display mode of the username. + * @default inline + */ + readonly mode = input('inline'); + /** + * Visual style of the username. + * @default default + */ + readonly type = input('default'); + /** + * Custom projected view for username rendering. + * @docs-private + */ + protected readonly customView = contentChild(KbqUsernameCustomView); + + /** @docs-private */ + protected readonly hasFullName = computed(() => { + const userInfo = this.userInfo(); + + if (!userInfo) return false; + + return userInfo?.lastName && userInfo?.firstName; + }); + + /** @docs-private */ + protected readonly class = computed(() => { + return [this.type(), this.mode()].map((modificator) => `${baseClass}_${modificator}`).join(' '); + }); +} diff --git a/packages/docs-examples/components/username/index.ts b/packages/docs-examples/components/username/index.ts new file mode 100644 index 000000000..424065191 --- /dev/null +++ b/packages/docs-examples/components/username/index.ts @@ -0,0 +1,20 @@ +import { NgModule } from '@angular/core'; +import { UsernameAsLinkExample } from './username-as-link/username-as-link-example'; +import { UsernameCustomExample } from './username-custom/username-custom-example'; +import { UsernameOverviewExample } from './username-overview/username-overview-example'; +import { UsernamePlaygroundExample } from './username-playground/username-playground-example'; + +export { UsernameAsLinkExample, UsernameCustomExample, UsernameOverviewExample, UsernamePlaygroundExample }; + +const EXAMPLES = [ + UsernameCustomExample, + UsernameOverviewExample, + UsernamePlaygroundExample, + UsernameAsLinkExample +]; + +@NgModule({ + imports: EXAMPLES, + exports: EXAMPLES +}) +export class UsernameExamplesModule {} diff --git a/packages/docs-examples/components/username/ng-package.json b/packages/docs-examples/components/username/ng-package.json new file mode 100644 index 000000000..bebf62dcb --- /dev/null +++ b/packages/docs-examples/components/username/ng-package.json @@ -0,0 +1,5 @@ +{ + "lib": { + "entryFile": "index.ts" + } +} diff --git a/packages/docs-examples/components/username/username-as-link/username-as-link-example.ts b/packages/docs-examples/components/username/username-as-link/username-as-link-example.ts new file mode 100644 index 000000000..6c948d068 --- /dev/null +++ b/packages/docs-examples/components/username/username-as-link/username-as-link-example.ts @@ -0,0 +1,24 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { KbqLinkModule } from '@koobiq/components/link'; +import { KbqUserInfo, KbqUsername } from '@koobiq/components/username'; + +/** + * @title Username as link + */ +@Component({ + selector: 'username-as-link-example', + standalone: true, + imports: [KbqUsername, KbqLinkModule], + template: ` + + `, + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class UsernameAsLinkExample { + userInfo: KbqUserInfo = { + firstName: 'Maxwell', + middleName: 'Alan', + lastName: 'Root', + login: 'mroot' + }; +} diff --git a/packages/docs-examples/components/username/username-custom/username-custom-example.ts b/packages/docs-examples/components/username/username-custom/username-custom-example.ts new file mode 100644 index 000000000..fc4e268f8 --- /dev/null +++ b/packages/docs-examples/components/username/username-custom/username-custom-example.ts @@ -0,0 +1,108 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { KbqFormFieldModule } from '@koobiq/components/form-field'; +import { KbqLinkModule } from '@koobiq/components/link'; +import { KbqRadioModule } from '@koobiq/components/radio'; +import { KbqTextareaModule } from '@koobiq/components/textarea'; +import { KbqTitleModule } from '@koobiq/components/title'; +import { + KBQ_PROFILE_MAPPING, + KbqFormatKeyToProfileMappingExtended, + KbqUserInfo, + KbqUsernameFormatKey, + KbqUsernameMode, + KbqUsernameModule, + KbqUsernameStyle +} from '@koobiq/components/username'; + +const mapping: KbqFormatKeyToProfileMappingExtended = { + [KbqUsernameFormatKey.FirstNameShort]: 'firstName', + [KbqUsernameFormatKey.FirstNameFull]: 'firstName', + + [KbqUsernameFormatKey.MiddleNameShort]: 'middleName', + [KbqUsernameFormatKey.MiddleNameFull]: 'middleName', + + [KbqUsernameFormatKey.LastNameShort]: 'lastName', + [KbqUsernameFormatKey.LastNameFull]: 'lastName', + + [KbqUsernameFormatKey.Dot]: undefined +}; + +/** + * @title Username custom + */ +@Component({ + selector: 'username-custom-example', + standalone: true, + imports: [ + FormsModule, + KbqUsernameModule, + KbqTextareaModule, + KbqFormFieldModule, + KbqLinkModule, + KbqRadioModule, + KbqTitleModule + ], + template: ` +
+ + + @let fullName = userInfo | kbqUsernameCustom: fullNameFormat : customMapping; + {{ fullName }} + + @if (userInfo?.login) { + [{{ userInfo?.login }}] + } + + +
+
+ + Name format + + + + + @for (usernameMode of modes; track usernameMode) { + + {{ usernameMode }} + + } + + + @for (usernameType of types; track usernameType) { + + {{ usernameType }} + + } + +
+ `, + styles: ` + .example-result { + padding: var(--kbq-size-m); + margin-bottom: var(--kbq-size-m); + background: var(--kbq-background-theme-less); + border-radius: var(--kbq-size-border-radius); + } + `, + providers: [ + { provide: KBQ_PROFILE_MAPPING, useValue: mapping }], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class UsernameCustomExample { + userInfo: KbqUserInfo = { + firstName: 'Maxwell', + middleName: 'Alan', + lastName: 'Root', + login: 'mroot' + }; + selectedMode: KbqUsernameMode = 'inline'; + selectedType: KbqUsernameStyle = 'default'; + fullNameFormat = 'F m. l.'; + + modes: KbqUsernameMode[] = ['inline', 'stacked', 'text']; + types: KbqUsernameStyle[] = ['default', 'error', 'accented', 'inherit']; + + readonly customMapping = mapping; +} diff --git a/packages/docs-examples/components/username/username-overview/username-overview-example.ts b/packages/docs-examples/components/username/username-overview/username-overview-example.ts new file mode 100644 index 000000000..e0b773a9a --- /dev/null +++ b/packages/docs-examples/components/username/username-overview/username-overview-example.ts @@ -0,0 +1,24 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { KbqUserInfo, KbqUsername } from '@koobiq/components/username'; + +/** + * @title Username overview + */ +@Component({ + selector: 'username-overview-example', + standalone: true, + imports: [KbqUsername], + template: ` + + `, + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class UsernameOverviewExample { + userInfo: KbqUserInfo = { + firstName: 'Maxwell', + middleName: 'Alan', + lastName: 'Root', + login: 'mroot', + site: 'corp' + }; +} diff --git a/packages/docs-examples/components/username/username-playground/username-playground-example.ts b/packages/docs-examples/components/username/username-playground/username-playground-example.ts new file mode 100644 index 000000000..d6650d382 --- /dev/null +++ b/packages/docs-examples/components/username/username-playground/username-playground-example.ts @@ -0,0 +1,80 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { KbqCheckboxModule } from '@koobiq/components/checkbox'; +import { KbqFormFieldModule } from '@koobiq/components/form-field'; +import { KbqRadioModule } from '@koobiq/components/radio'; +import { KbqTextareaModule } from '@koobiq/components/textarea'; +import { KbqUserInfo, KbqUsername, KbqUsernameMode, KbqUsernameStyle } from '@koobiq/components/username'; + +/** + * @title Username playground + */ +@Component({ + selector: 'username-playground-example', + standalone: true, + imports: [ + FormsModule, + KbqUsername, + KbqTextareaModule, + KbqFormFieldModule, + KbqCheckboxModule, + KbqRadioModule + ], + template: ` +
+ +
+
+ + Name format + + + + isCompact + + @for (usernameMode of modes; track usernameMode) { + + {{ usernameMode }} + + } + + + @for (usernameType of types; track usernameType) { + + {{ usernameType }} + + } + +
+ `, + styles: ` + .example-result { + padding: var(--kbq-size-m); + margin-bottom: var(--kbq-size-m); + background: var(--kbq-background-theme-less); + border-radius: var(--kbq-size-border-radius); + } + `, + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class UsernamePlaygroundExample { + userInfo: KbqUserInfo = { + firstName: 'Maxwell', + middleName: 'Alan', + lastName: 'Root', + login: 'mroot' + }; + selectedMode: KbqUsernameMode = 'inline'; + selectedType: KbqUsernameStyle = 'default'; + isCompact = false; + fullNameFormat = 'f.m.l'; + + modes: KbqUsernameMode[] = ['inline', 'stacked', 'text']; + types: KbqUsernameStyle[] = ['default', 'error', 'accented', 'inherit']; +} diff --git a/packages/docs-examples/example-module.ts b/packages/docs-examples/example-module.ts index e2d4db2ec..60b2ae173 100644 --- a/packages/docs-examples/example-module.ts +++ b/packages/docs-examples/example-module.ts @@ -5411,4 +5411,4 @@ return import('@koobiq/docs-examples/components/validation'); default: return undefined; } -} \ No newline at end of file +} diff --git a/tools/api-extractor/config.json b/tools/api-extractor/config.json index 6700ceb7c..78ea802d3 100644 --- a/tools/api-extractor/config.json +++ b/tools/api-extractor/config.json @@ -52,7 +52,8 @@ "toggle", "tooltip", "tree", - "tree-select" + "tree-select", + "username" ], "components-experimental": [ "form-field" diff --git a/tools/generate-sitemap.ts b/tools/generate-sitemap.ts index 046fdb0eb..802196e51 100644 --- a/tools/generate-sitemap.ts +++ b/tools/generate-sitemap.ts @@ -186,6 +186,9 @@ const paths = [ 'components/tree-select/overview', 'components/tree-select/api', + 'components/username/overview', + 'components/username/api', + // ---------------------- Other ---------------------- 'other/date-formatter/overview', 'other/date-formatter/api', diff --git a/tools/public_api_guard/components/username.api.md b/tools/public_api_guard/components/username.api.md new file mode 100644 index 000000000..91f4e8e00 --- /dev/null +++ b/tools/public_api_guard/components/username.api.md @@ -0,0 +1,148 @@ +## API Report File for "koobiq" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import * as i0 from '@angular/core'; +import { InjectionToken } from '@angular/core'; +import { InputSignal } from '@angular/core'; +import { InputSignalWithTransform } from '@angular/core'; +import { PipeTransform } from '@angular/core'; +import { Signal } from '@angular/core'; + +// @public +export const KBQ_PROFILE_MAPPING: InjectionToken; + +// @public +export const kbqDefaultFullNameFormat = "lf.m."; + +// @public +export const kbqDefaultFullNameFormatCustom = "L f. m."; + +// @public +export type KbqFormatKeyToProfileMapping = { + [key in Exclude]: keyof T | undefined; +}; + +// @public +export type KbqFormatKeyToProfileMappingExtended = { + [key in KbqUsernameFormatKey]: keyof T | undefined; +}; + +// @public +export function KbqMappingMissingError(): Error; + +// @public +export type KbqUserInfo = { + firstName?: string; + lastName?: string; + middleName?: string; + login?: string; + site?: string; +}; + +// @public +export class KbqUsername { + protected readonly class: Signal; + protected readonly customView: Signal; + readonly fullNameFormat: InputSignal; + protected readonly hasFullName: Signal; + readonly isCompact: InputSignalWithTransform; + readonly mode: InputSignal; + readonly type: InputSignal; + readonly userInfo: InputSignal; + // (undocumented) + static ɵcmp: i0.ɵɵComponentDeclaration; + // (undocumented) + static ɵfac: i0.ɵɵFactoryDeclaration; +} + +// @public +export class KbqUsernameCustomPipe implements PipeTransform { + transform(profile: T, format?: string, customMapping?: KbqFormatKeyToProfileMappingExtended): string; + // (undocumented) + static ɵfac: i0.ɵɵFactoryDeclaration, never>; + // (undocumented) + static ɵpipe: i0.ɵɵPipeDeclaration, "kbqUsernameCustom", true>; + // (undocumented) + static ɵprov: i0.ɵɵInjectableDeclaration>; +} + +// @public +export class KbqUsernameCustomView { + // (undocumented) + static ɵdir: i0.ɵɵDirectiveDeclaration; + // (undocumented) + static ɵfac: i0.ɵɵFactoryDeclaration; +} + +// @public +export enum KbqUsernameFormatKey { + // (undocumented) + Dot = ".", + FirstNameFull = "F", + FirstNameShort = "f", + LastNameFull = "L", + LastNameShort = "l", + MiddleNameFull = "M", + MiddleNameShort = "m" +} + +// @public +export type KbqUsernameMode = 'stacked' | 'inline' | 'text'; + +// @public (undocumented) +export class KbqUsernameModule { + // (undocumented) + static ɵfac: i0.ɵɵFactoryDeclaration; + // (undocumented) + static ɵinj: i0.ɵɵInjectorDeclaration; + // Warning: (ae-forgotten-export) The symbol "i1" needs to be exported by the entry point index.d.ts + // Warning: (ae-forgotten-export) The symbol "i2" needs to be exported by the entry point index.d.ts + // + // (undocumented) + static ɵmod: i0.ɵɵNgModuleDeclaration; +} + +// @public (undocumented) +export class KbqUsernamePipe implements PipeTransform { + transform(profile: T, format?: string, customMapping?: KbqFormatKeyToProfileMapping): string; + // (undocumented) + static ɵfac: i0.ɵɵFactoryDeclaration, never>; + // (undocumented) + static ɵpipe: i0.ɵɵPipeDeclaration, "kbqUsername", true>; + // (undocumented) + static ɵprov: i0.ɵɵInjectableDeclaration>; +} + +// @public +export class KbqUsernamePrimary { + // (undocumented) + static ɵdir: i0.ɵɵDirectiveDeclaration; + // (undocumented) + static ɵfac: i0.ɵɵFactoryDeclaration; +} + +// @public +export class KbqUsernameSecondary { + // (undocumented) + static ɵdir: i0.ɵɵDirectiveDeclaration; + // (undocumented) + static ɵfac: i0.ɵɵFactoryDeclaration; +} + +// @public +export class KbqUsernameSecondaryHint { + // (undocumented) + static ɵdir: i0.ɵɵDirectiveDeclaration; + // (undocumented) + static ɵfac: i0.ɵɵFactoryDeclaration; +} + +// @public +export type KbqUsernameStyle = 'default' | 'error' | 'accented' | 'inherit'; + +// (No @packageDocumentation comment for this package) + +``` diff --git a/tsconfig.json b/tsconfig.json index 16111876b..5d93d9043 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -82,6 +82,7 @@ "@koobiq/components/top-bar": ["packages/components/top-bar/index.ts"], "@koobiq/components/tree": ["packages/components/tree/index.ts"], "@koobiq/components/tree-select": ["packages/components/tree-select/index.ts"], + "@koobiq/components/username": ["packages/components/username/index.ts"], "@koobiq/components/vertical-navbar": ["packages/components/vertical-navbar/index.ts"], "@koobiq/angular-luxon-adapter": ["packages/angular-luxon-adapter/index.ts"], "@koobiq/angular-moment-adapter": ["packages/angular-moment-adapter/index.ts"],