diff --git a/packages/components/src/components/Username/Username.mdx b/packages/components/src/components/Username/Username.mdx new file mode 100644 index 00000000..8b6bb557 --- /dev/null +++ b/packages/components/src/components/Username/Username.mdx @@ -0,0 +1,196 @@ +import { + Meta, + Story, + Props, + Status, +} from '../../../../../.storybook/components'; + +import * as Stories from './Username.stories'; + + + +# Username + + + +Username is used when an interface needs to reference an internal system user. +It presents a set of identifying attributes — full name, login, and site — in a consistent layout. + +## Import + +```tsx +import { + Username, + UsernamePrimary, + UsernameSecondary, + UsernameSecondaryHint, +} from '@koobiq/react-components'; +``` + +## Usage + +Pass a `userInfo` object to render the default template automatically. + + + +## Props + + + +## Mode + +The `mode` prop controls the layout of the name parts. + +- `inline` — displays primary and secondary on one line. Both parts have ellipsis. This is the default. +- `stacked` — displays primary and secondary on separate lines. +- `text` — renders as inline text with no ellipsis, suitable for use inside a paragraph. + + + +## Type + +The `type` prop sets the visual color style. + +- `default` — primary text in contrast color, secondary in secondary-contrast color. +- `error` — all parts in error color. +- `accented` — same colors as `default`, but primary text uses bold weight. Use for typographic emphasis without introducing color. +- `inherit` — all parts inherit color from the parent element. + + + +## Compact + +When `isCompact` is `true`, primary and secondary are collapsed into a single line: +the full name (or login if no full name is available) is shown, followed by the site in parentheses. + + + +## Login only + +When `firstName` or `lastName` is absent from `userInfo`, the component falls back to showing +the `login` value as the primary content. + + + +## With site + +The optional `site` field renders as a hint after the login. + + + +## Custom view + +Provide `children` to take full control of the output. +The default template is skipped entirely; `mode` and `type` still apply to the root element. + +Use `UsernamePrimary`, `UsernameSecondary`, and `UsernameSecondaryHint` to keep the same visual +style as the default template. + + + +## As link + +Use `type="inherit"` when placing `Username` inside a link so it inherits the link's color. + + + +## Name format + +The `fullNameFormat` prop controls how the full name is assembled from `userInfo`. + +| Character | Field | Behavior | +| --------- | ------------ | ------------------------------------------------- | +| `l` | `lastName` | Full value, or initial + `.` when followed by `.` | +| `f` | `firstName` | Full value, or initial + `.` when followed by `.` | +| `m` | `middleName` | Full value, or initial + `.` when followed by `.` | +| `.` | — | Separator marker — not emitted literally | + +The default format `'lf.m.'` produces `"Root M. A."` for a user with +`lastName: 'Root'`, `firstName: 'Maxwell'`, `middleName: 'Alan'`. + +Empty fields are silently skipped without leaving stray punctuation. + +Use the exported `formatUsername` utility to get the same string in non-JSX contexts: + +```tsx +import { formatUsername } from '@koobiq/react-components'; + +const name = formatUsername( + { firstName: 'Maxwell', middleName: 'Alan', lastName: 'Root' }, + 'lf.m.' +); +// → "Root M. A." +``` + +## Custom formatter + +The `formatter` prop lets you swap the name-formatting function. +Pass `formatUsernameCustom` (or any function with the same signature) to use a different format style. + +`formatUsernameCustom` uses a richer format string where character case determines full vs initial output, +and non-field characters pass through literally: + +| Character | Field | Output | +| --------- | ------------ | -------------------------------------------- | +| `L` | `lastName` | Full value | +| `l` | `lastName` | First character only | +| `F` | `firstName` | Full value | +| `f` | `firstName` | First character only | +| `M` | `middleName` | Full value | +| `m` | `middleName` | First character only | +| any other | — | Emitted literally (spaces, dots, separators) | + +The default format for `formatUsernameCustom` is `'L f. m.'`, which produces the same +`"Root M. A."` output as the default formatter but with literal dots and spaces from the +format string rather than token-joining. + + + +```tsx +import { formatUsernameCustom } from '@koobiq/react-components'; + +; +// Primary renders: "Maxwell Root" +``` + +A custom `mapping` can be passed by wrapping the function: + +```tsx + formatUsernameCustom(info, fmt, myMapping)} + fullNameFormat="F L" +/> +``` + +## buildUsernameText + +`buildUsernameText` assembles a plain-text string that mirrors what `Username` renders. +Use it for `aria-label` values or search/filter logic. + +It takes a pre-formatted `name` string (the output of `formatUsername` or `formatUsernameCustom`) +plus optional `login` and `site`. By default `site` is wrapped in parentheses. + +```tsx +import { buildUsernameText, formatUsername } from '@koobiq/react-components'; + +const name = formatUsername(userInfo, 'lf.m.'); +buildUsernameText({ name, login: userInfo.login, site: userInfo.site }); +// → "Root M. A. mroot (corp)" +``` + +Use `formatLogin` and `formatSite` options to customise how those segments appear: + +```tsx +// Remove parentheses from site (useful for search comparisons) +buildUsernameText({ name, login, site }, { formatSite: (s) => s }); +// → "Root M. A. mroot corp" + +// Wrap login in brackets +buildUsernameText({ name, login, site }, { formatLogin: (l) => `[${l}]` }); +// → "Root M. A. [mroot] (corp)" +``` diff --git a/packages/components/src/components/Username/Username.module.css b/packages/components/src/components/Username/Username.module.css new file mode 100644 index 00000000..ab576792 --- /dev/null +++ b/packages/components/src/components/Username/Username.module.css @@ -0,0 +1,94 @@ +@import url('../../styles/mixins.css'); + +/* ── Root ─────────────────────────────────────────────────────── */ + +.base { + --username-color-primary: ; + --username-color-secondary: ; + --username-color-hint: ; + + @mixin typography text-normal; + + display: inline-flex; + max-inline-size: 100%; + box-sizing: border-box; +} + +/* ── Mode modifiers ───────────────────────────────────────────── */ + +.stacked { + flex-direction: column; + align-items: flex-start; +} + +.inline { + flex-direction: row; + align-items: baseline; + gap: var(--kbq-size-xxs); +} + +.text { + display: inline; +} + +/* ── Type modifiers — populate custom properties ─────────────── */ + +.default { + --username-color-primary: var(--kbq-foreground-contrast); + --username-color-secondary: var(--kbq-foreground-contrast-secondary); + --username-color-hint: var(--kbq-foreground-contrast-secondary); +} + +.error { + --username-color-primary: var(--kbq-foreground-error); + --username-color-secondary: var(--kbq-foreground-error); + --username-color-hint: var(--kbq-foreground-error); +} + +.accented { + --username-color-primary: var(--kbq-foreground-contrast); + --username-color-secondary: var(--kbq-foreground-contrast-secondary); + --username-color-hint: var(--kbq-foreground-contrast-secondary); +} + +.inherit { + --username-color-primary: inherit; + --username-color-secondary: inherit; + --username-color-hint: inherit; + + font: inherit; + display: inherit; +} + +/* ── Sub-element classes ─────────────────────────────────────── */ + +.primary { + @mixin ellipsis; + + color: var(--username-color-primary); +} + +.secondary { + @mixin ellipsis; + + color: var(--username-color-secondary); +} + +.secondaryHint { + color: var(--username-color-hint); +} + +/* accented type: primary uses bold weight */ + +.accented .primary { + @mixin typography text-normal-strong; +} + +/* text mode: remove ellipsis so content flows as prose */ + +.text .primary, +.text .secondary { + overflow: visible; + white-space: normal; + text-overflow: unset; +} diff --git a/packages/components/src/components/Username/Username.stories.tsx b/packages/components/src/components/Username/Username.stories.tsx new file mode 100644 index 00000000..c9b4bc8f --- /dev/null +++ b/packages/components/src/components/Username/Username.stories.tsx @@ -0,0 +1,231 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { FlexBox } from '../FlexBox'; +import { Link } from '../Link'; + +import { + Username, + UsernamePrimary, + UsernameSecondary, + UsernameSecondaryHint, + type UsernameProps, + formatUsernameCustom, + usernamePropMode, + usernamePropType, +} from './index.js'; + +const defaultUserInfo = { + firstName: 'Maxwell', + middleName: 'Alan', + lastName: 'Root', + login: 'mroot', + site: 'corp', +}; + +const meta = { + title: 'Components/Username', + component: Username, + parameters: { + layout: 'centered', + }, + tags: ['status:new', 'date:2026-07-22'], + args: { + userInfo: defaultUserInfo, + }, + argTypes: { + mode: { control: { type: 'select' }, options: usernamePropMode }, + type: { control: { type: 'select' }, options: usernamePropType }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Base: Story = { + render: (args) => , +}; + +export const Mode: Story = { + render: (args) => ( + + {usernamePropMode.map((mode) => ( + + + mode = {mode} + + + + ))} + + ), +}; + +export const Type: Story = { + render: (args) => ( + + {usernamePropType.map((type) => ( + + + type = {type} + + {type === 'inherit' ? ( + + + + ) : ( + + )} + + ))} + + ), +}; + +export const Compact: Story = { + render: (args) => ( + + + + isCompact = false + + + + + + isCompact = true + + + + + ), +}; + +export const OnlyLogin: Story = { + render: (args) => ( + + ), +}; + +export const WithSite: Story = { + render: (args) => ( + + + + non-compact with site + + + + + + compact with site + + + + + ), +}; + +export const CustomView: Story = { + render: (args) => ( + + Root M. A. + + [mroot] + (corp) + + + ), +}; + +export const AsLink: Story = { + render: (args) => ( + + + + ), +}; + +export const CustomFormatter: Story = { + render: (args) => ( + + + + formatUsername (default) — format: 'lf.m.' + + + + + + formatUsernameCustom — format: 'L f. m.' + + + + + + formatUsernameCustom — format: 'F L' + + + + + ), +}; diff --git a/packages/components/src/components/Username/Username.test.tsx b/packages/components/src/components/Username/Username.test.tsx new file mode 100644 index 00000000..b037b9f1 --- /dev/null +++ b/packages/components/src/components/Username/Username.test.tsx @@ -0,0 +1,330 @@ +import { createRef } from 'react'; + +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { + Username, + UsernamePrimary, + buildUsernameText, + formatUsername, + formatUsernameCustom, + usernamePropMode, + usernamePropType, +} from './index.js'; + +const fullProfile = { + firstName: 'Maxwell', + middleName: 'Alan', + lastName: 'Root', + login: 'mroot', + site: 'corp', +}; + +describe('Username', () => { + const baseProps = { 'data-testid': 'username' }; + const getRoot = () => screen.getByTestId('username'); + + it('should forward a ref to the root span element', () => { + const ref = createRef(); + const { container } = render(); + expect(ref.current).toBe(container.firstElementChild); + }); + + it('should merge a custom className with component classes', () => { + render(); + expect(getRoot()).toHaveClass('custom'); + }); + + it('should spread additional HTML props onto the root element', () => { + render(); + expect(getRoot()).toHaveAttribute('aria-label', 'User'); + }); + + describe('default data attributes', () => { + it('should set data-mode to "inline" by default', () => { + render(); + expect(getRoot()).toHaveAttribute('data-mode', 'inline'); + }); + + it('should set data-type to "default" by default', () => { + render(); + expect(getRoot()).toHaveAttribute('data-type', 'default'); + }); + + it('should not set data-compact by default', () => { + render(); + expect(getRoot()).not.toHaveAttribute('data-compact'); + }); + }); + + describe('prop reflection', () => { + it.each(usernamePropMode)('should set data-mode to "%s"', (mode) => { + render(); + expect(getRoot()).toHaveAttribute('data-mode', mode); + }); + + it.each(usernamePropType)('should set data-type to "%s"', (type) => { + render(); + expect(getRoot()).toHaveAttribute('data-type', type); + }); + + it('should set data-compact when isCompact is true', () => { + render(); + expect(getRoot()).toHaveAttribute('data-compact'); + }); + }); + + describe('default template rendering (non-compact)', () => { + it('should render the formatted full name in Primary when firstName and lastName are present', () => { + render(); + expect(screen.getByText('Root M. A.')).toBeInTheDocument(); + }); + + it('should apply the fullNameFormat prop to the rendered name', () => { + render(); + expect(screen.getByText('Root M.')).toBeInTheDocument(); + }); + + it('should render login in Secondary when login is provided', () => { + render(); + const secondary = document.querySelector('[class*="secondary"]'); + expect(secondary?.textContent).toContain('mroot'); + }); + + it('should render site as SecondaryHint inside Secondary when site is provided', () => { + render(); + expect(screen.getByText('(corp)', { exact: false })).toBeInTheDocument(); + }); + + it('should render login in Primary when firstName is missing', () => { + render(); + const primary = document.querySelector('[class*="primary"]'); + expect(primary?.textContent).toContain('mroot'); + }); + + it('should render login in Primary when lastName is missing', () => { + render(); + const primary = document.querySelector('[class*="primary"]'); + expect(primary?.textContent).toContain('mroot'); + }); + + it('should render login as primary content when full name is absent', () => { + render(); + const primary = document.querySelector('[class*="primary"]'); + expect(primary?.textContent).toContain('mroot'); + }); + }); + + describe('compact mode', () => { + it('should render a single Primary containing the formatted name', () => { + render(); + const primary = document.querySelector('[class*="primary"]'); + expect(primary?.textContent).toContain('Root M. A.'); + }); + + it('should render login inside Primary in compact mode when full name is absent', () => { + render(); + const primary = document.querySelector('[class*="primary"]'); + expect(primary?.textContent).toContain('mroot'); + }); + + it('should render site as SecondaryHint inside Primary in compact mode', () => { + render(); + const primary = document.querySelector('[class*="primary"]'); + expect(primary?.textContent).toContain('(corp)'); + }); + + it('should not render site when neither full name nor login is available in compact mode', () => { + render(); + expect(screen.queryByText('(corp)', { exact: false })).toBeNull(); + }); + }); + + describe('formatter prop', () => { + it('should use the provided formatter instead of formatUsername', () => { + const customFormatter = vi.fn().mockReturnValue('Custom Name'); + render(); + render(); + expect(customFormatter).toHaveBeenCalledWith(fullProfile, 'lf.m.'); + expect(screen.getByText('Custom Name')).toBeInTheDocument(); + }); + + it('should pass fullNameFormat to the formatter', () => { + render( + + ); + + expect(screen.getByText('Maxwell Root')).toBeInTheDocument(); + }); + }); + + describe('custom view (children)', () => { + it('should render children instead of the default template', () => { + render( + + Custom Name + + ); + + expect(screen.getByText('Custom Name')).toBeInTheDocument(); + }); + + it('should not render auto-generated content when children are provided', () => { + render( + + Custom + + ); + + expect(screen.queryByText('Root M. A.')).toBeNull(); + expect(screen.queryByText('mroot')).toBeNull(); + }); + + it('should still apply data attributes to the root span in custom view', () => { + render( + + Custom + + ); + + expect(getRoot()).toHaveAttribute('data-mode', 'stacked'); + expect(getRoot()).toHaveAttribute('data-type', 'error'); + }); + }); +}); + +describe('formatUsername', () => { + it('should return an empty string when userInfo is undefined', () => { + expect(formatUsername(undefined)).toBe(''); + }); + + it('should format "lf.m." correctly with all three name parts present', () => { + expect( + formatUsername( + { firstName: 'Maxwell', middleName: 'Alan', lastName: 'Root' }, + 'lf.m.' + ) + ).toBe('Root M. A.'); + }); + + it('should skip empty fields without leaving stray dots', () => { + expect( + formatUsername({ firstName: 'Maxwell', lastName: 'Root' }, 'lf.m.') + ).toBe('Root M.'); + }); + + it('should emit the full field value when the next character is not a dot', () => { + expect( + formatUsername({ firstName: 'Maxwell', lastName: 'Root' }, 'lf') + ).toBe('Root Maxwell'); + }); + + it('should handle a custom format string', () => { + expect( + formatUsername({ firstName: 'Maxwell', lastName: 'Root' }, 'fl.') + ).toBe('Maxwell R.'); + }); +}); + +describe('formatUsernameCustom', () => { + it('should return an empty string when userInfo is undefined', () => { + expect(formatUsernameCustom(undefined)).toBe(''); + }); + + it('should format "L f. m." correctly with all three name parts present', () => { + expect( + formatUsernameCustom( + { firstName: 'Maxwell', middleName: 'Alan', lastName: 'Root' }, + 'L f. m.' + ) + ).toBe('Root M. A.'); + }); + + it('should emit the full value for uppercase keys', () => { + expect( + formatUsernameCustom({ firstName: 'Maxwell', lastName: 'Root' }, 'F L') + ).toBe('Maxwell Root'); + }); + + it('should emit only the initial for lowercase keys', () => { + expect( + formatUsernameCustom({ firstName: 'Maxwell', lastName: 'Root' }, 'f l') + ).toBe('M R'); + }); + + it('should emit non-field characters literally', () => { + expect( + formatUsernameCustom({ firstName: 'Maxwell', lastName: 'Root' }, 'L, F') + ).toBe('Root, Maxwell'); + }); + + it('should silently drop an empty field key while preserving surrounding literals', () => { + expect( + formatUsernameCustom( + { firstName: 'Maxwell', lastName: 'Root' }, + 'L f. m.' + ) + ).toBe('Root M. .'); + }); + + it('should accept a custom mapping', () => { + expect( + formatUsernameCustom({ firstName: 'Maxwell', lastName: 'Root' }, 'A', { + A: 'firstName', + }) + ).toBe('Maxwell'); + }); +}); + +describe('buildUsernameText', () => { + it('should return only the name when login and site are absent', () => { + expect(buildUsernameText({ name: 'Root M. A.' })).toBe('Root M. A.'); + }); + + it('should append login after the name', () => { + expect(buildUsernameText({ name: 'Root M. A.', login: 'mroot' })).toBe( + 'Root M. A. mroot' + ); + }); + + it('should wrap site in parentheses by default', () => { + expect( + buildUsernameText({ name: 'Root M. A.', login: 'mroot', site: 'corp' }) + ).toBe('Root M. A. mroot (corp)'); + }); + + it('should apply a custom formatSite', () => { + expect( + buildUsernameText( + { name: 'Root M. A.', login: 'mroot', site: 'corp' }, + { formatSite: (s) => s } + ) + ).toBe('Root M. A. mroot corp'); + }); + + it('should apply a custom formatLogin', () => { + expect( + buildUsernameText( + { name: 'Root M. A.', login: 'mroot', site: 'corp' }, + { formatLogin: (l) => `[${l}]` } + ) + ).toBe('Root M. A. [mroot] (corp)'); + }); + + it('should omit login segment when login is absent', () => { + expect(buildUsernameText({ name: 'Root M. A.', site: 'corp' })).toBe( + 'Root M. A. (corp)' + ); + }); + + it('should omit site segment when site is absent', () => { + expect(buildUsernameText({ name: 'Root M. A.', login: 'mroot' })).toBe( + 'Root M. A. mroot' + ); + }); +}); diff --git a/packages/components/src/components/Username/Username.tsx b/packages/components/src/components/Username/Username.tsx new file mode 100644 index 00000000..0e00065d --- /dev/null +++ b/packages/components/src/components/Username/Username.tsx @@ -0,0 +1,109 @@ +'use client'; + +import { + forwardRef, + type ComponentPropsWithRef, + type ComponentRef, +} from 'react'; + +import { clsx, isNotNil } from '@koobiq/react-core'; + +import type { UsernameBaseProps } from './types'; +import s from './Username.module.css'; +import { UsernamePrimary } from './UsernamePrimary'; +import { UsernameSecondary } from './UsernameSecondary'; +import { UsernameSecondaryHint } from './UsernameSecondaryHint'; +import { formatUsername } from './utils'; + +/** + * Displays a user's name based on profile data. + * Supports different layout modes and visual styles. + * Provide `children` to take full control of the rendered content. + */ +export const Username = forwardRef, UsernameBaseProps>( + (props, ref) => { + const { + userInfo, + mode = 'inline', + type = 'default', + isCompact = false, + fullNameFormat = 'lf.m.', + formatter = formatUsername, + children, + className, + ...other + } = props; + + const rootClassName = clsx(s.base, s[mode], s[type], className); + + const rootProps = { + 'data-mode': mode, + 'data-type': type, + className: rootClassName, + ref, + ...other, + }; + + // When children are provided, render as a custom view (overrides default template). + if (isNotNil(children)) { + return ( + + {children} + + ); + } + + const hasFullName = Boolean(userInfo?.firstName && userInfo?.lastName); + const name = hasFullName ? formatter(userInfo, fullNameFormat) : ''; + const hasContent = hasFullName || isNotNil(userInfo?.login); + + if (isCompact) { + return ( + + + {hasFullName ? name : userInfo?.login} + {hasContent && isNotNil(userInfo?.site) && ( + ({userInfo!.site}) + )} + + + ); + } + + if (hasFullName) { + return ( + + {name} + {isNotNil(userInfo?.login) && ( + + {userInfo!.login} + {isNotNil(userInfo?.site) && ( + + {' '} + ({userInfo!.site}) + + )} + + )} + + ); + } + + return ( + + {isNotNil(userInfo?.login) && ( + + {userInfo!.login} + {isNotNil(userInfo?.site) && ( + ({userInfo!.site}) + )} + + )} + + ); + } +); + +Username.displayName = 'Username'; + +export type UsernameProps = ComponentPropsWithRef; diff --git a/packages/components/src/components/Username/UsernamePrimary.tsx b/packages/components/src/components/Username/UsernamePrimary.tsx new file mode 100644 index 00000000..48c29294 --- /dev/null +++ b/packages/components/src/components/Username/UsernamePrimary.tsx @@ -0,0 +1,19 @@ +'use client'; + +import { forwardRef, type ComponentRef } from 'react'; + +import { clsx } from '@koobiq/react-core'; + +import type { UsernamePrimaryProps } from './types'; +import s from './Username.module.css'; + +export const UsernamePrimary = forwardRef< + ComponentRef<'span'>, + UsernamePrimaryProps +>(({ className, children, ...other }, ref) => ( + + {children} + +)); + +UsernamePrimary.displayName = 'UsernamePrimary'; diff --git a/packages/components/src/components/Username/UsernameSecondary.tsx b/packages/components/src/components/Username/UsernameSecondary.tsx new file mode 100644 index 00000000..fbbd3cde --- /dev/null +++ b/packages/components/src/components/Username/UsernameSecondary.tsx @@ -0,0 +1,19 @@ +'use client'; + +import { forwardRef, type ComponentRef } from 'react'; + +import { clsx } from '@koobiq/react-core'; + +import type { UsernameSecondaryProps } from './types'; +import s from './Username.module.css'; + +export const UsernameSecondary = forwardRef< + ComponentRef<'span'>, + UsernameSecondaryProps +>(({ className, children, ...other }, ref) => ( + + {children} + +)); + +UsernameSecondary.displayName = 'UsernameSecondary'; diff --git a/packages/components/src/components/Username/UsernameSecondaryHint.tsx b/packages/components/src/components/Username/UsernameSecondaryHint.tsx new file mode 100644 index 00000000..4a3ce249 --- /dev/null +++ b/packages/components/src/components/Username/UsernameSecondaryHint.tsx @@ -0,0 +1,19 @@ +'use client'; + +import { forwardRef, type ComponentRef } from 'react'; + +import { clsx } from '@koobiq/react-core'; + +import type { UsernameSecondaryHintProps } from './types'; +import s from './Username.module.css'; + +export const UsernameSecondaryHint = forwardRef< + ComponentRef<'span'>, + UsernameSecondaryHintProps +>(({ className, children, ...other }, ref) => ( + + {children} + +)); + +UsernameSecondaryHint.displayName = 'UsernameSecondaryHint'; diff --git a/packages/components/src/components/Username/index.ts b/packages/components/src/components/Username/index.ts new file mode 100644 index 00000000..174cb12e --- /dev/null +++ b/packages/components/src/components/Username/index.ts @@ -0,0 +1,6 @@ +export * from './Username'; +export * from './UsernamePrimary'; +export * from './UsernameSecondary'; +export * from './UsernameSecondaryHint'; +export * from './types'; +export * from './utils'; diff --git a/packages/components/src/components/Username/types.ts b/packages/components/src/components/Username/types.ts new file mode 100644 index 00000000..d9809bc4 --- /dev/null +++ b/packages/components/src/components/Username/types.ts @@ -0,0 +1,64 @@ +import type { ComponentPropsWithRef, ReactNode } from 'react'; + +export const usernamePropMode = ['stacked', 'inline', 'text'] as const; +export type UsernamePropMode = (typeof usernamePropMode)[number]; + +export const usernamePropType = [ + 'default', + 'error', + 'accented', + 'inherit', +] as const; +export type UsernamePropType = (typeof usernamePropType)[number]; + +export type UsernameUserInfo = { + firstName?: string; + lastName?: string; + middleName?: string; + login?: string; + site?: string; +}; + +export type UsernameBaseProps = Omit< + ComponentPropsWithRef<'span'>, + 'children' +> & { + /** User profile data. Not required when children (custom view) is provided. */ + userInfo?: UsernameUserInfo; + /** + * Layout mode. + * @default 'inline' + */ + mode?: UsernamePropMode; + /** + * Visual color type. + * @default 'default' + */ + type?: UsernamePropType; + /** + * When true, collapses primary and secondary into a single line. + * @default false + */ + isCompact?: boolean; + /** + * Format string passed to `formatter`. + * Interpretation depends on which formatter is used. + * @default 'lf.m.' + */ + fullNameFormat?: string; + /** + * Function used to format the full name from `userInfo`. + * Accepts `formatUsername` (default) or `formatUsernameCustom`. + * Signature: `(userInfo, format) => string` + */ + formatter?: ( + userInfo: UsernameUserInfo | undefined, + format: string + ) => string; + /** When provided, entirely replaces the auto-rendered template. */ + children?: ReactNode; +}; + +export type UsernamePrimaryProps = ComponentPropsWithRef<'span'>; +export type UsernameSecondaryProps = ComponentPropsWithRef<'span'>; +export type UsernameSecondaryHintProps = ComponentPropsWithRef<'span'>; diff --git a/packages/components/src/components/Username/utils.ts b/packages/components/src/components/Username/utils.ts new file mode 100644 index 00000000..d65f8d75 --- /dev/null +++ b/packages/components/src/components/Username/utils.ts @@ -0,0 +1,160 @@ +import type { UsernameUserInfo } from './types'; + +const fieldMap: Record = { + l: 'lastName', + f: 'firstName', + m: 'middleName', +}; + +/** + * Maps format-string characters to `UsernameUserInfo` field names. + * Used by `formatUsernameCustom`. Characters absent from the mapping + * (or mapped to `undefined`) are emitted as literals. + */ +export type UsernameFormatMapping = Record< + string, + keyof UsernameUserInfo | undefined +>; + +const defaultCustomMapping: UsernameFormatMapping = { + F: 'firstName', + f: 'firstName', + M: 'middleName', + m: 'middleName', + L: 'lastName', + l: 'lastName', +}; + +/** + * Formats user profile data into a display name string using a format pattern. + * + * Format characters: `l` = lastName, `f` = firstName, `m` = middleName. + * A letter followed by `.` produces an initial + period (e.g. `f.` → "M."). + * Empty fields are silently skipped without leaving stray punctuation. + * @example + * formatUsername({ firstName: 'Maxwell', middleName: 'Alan', lastName: 'Root' }, 'lf.m.') + * // → "Root M. A." + */ +export function formatUsername( + userInfo: UsernameUserInfo | undefined, + format = 'lf.m.' +): string { + if (!userInfo) return ''; + + const tokens: string[] = []; + let i = 0; + + while (i < format.length) { + const char = format[i]; + const fieldKey = fieldMap[char]; + + if (fieldKey) { + const value = userInfo[fieldKey]?.trim() ?? ''; + const takesInitial = format[i + 1] === '.'; + + if (value) { + tokens.push(takesInitial ? `${value[0].toUpperCase()}.` : value); + } + + // consume the trailing '.' whether the field had a value + if (takesInitial) i += 1; + } + + // standalone '.' is only a separator marker — never emitted literally + i += 1; + } + + return tokens.join(' '); +} + +/** + * Formats user profile data into a display name string using a richer format pattern. + * + * - Uppercase letters (`L`, `F`, `M`) → full field value. + * - Lowercase letters (`l`, `f`, `m`) → first character (initial) only. + * - All other characters are emitted literally (spaces, dots, slashes, etc.). + * - Empty fields are silently dropped; surrounding literal characters still emit. + * + * Default format `'L f. m.'` produces `"Root M. A."` for + * `{ firstName: 'Maxwell', middleName: 'Alan', lastName: 'Root' }`. + * + * Pass a custom `mapping` to bind format keys to different `UsernameUserInfo` fields. + * @example + * formatUsernameCustom({ firstName: 'Maxwell', middleName: 'Alan', lastName: 'Root' }, 'L f. m.') + * // → "Root M. A." + */ +export function formatUsernameCustom( + userInfo: UsernameUserInfo | undefined, + format = 'L f. m.', + mapping: UsernameFormatMapping = defaultCustomMapping +): string { + if (!userInfo) return ''; + + let result = ''; + + for (const char of format) { + if (!(char in mapping)) { + result += char; + continue; + } + + const fieldKey = mapping[char]; + + if (!fieldKey) { + result += char; + continue; + } + + const value = userInfo[fieldKey]?.trim() ?? ''; + + if (value) { + result += char === char.toLowerCase() ? value[0] : value; + } + } + + return result.trim(); +} + +export type BuildUsernameTextOptions = { + /** + * Formats the login segment. + * @default (login) => login + */ + formatLogin?: (login: string) => string; + /** + * Formats the site segment. + * @default (site) => `(${site})` + */ + formatSite?: (site: string) => string; +}; + +/** + * Builds a plain-text string from a pre-formatted name plus optional login and site, + * mirroring the text rendered by `Username`. + * + * Pass the result of `formatUsername` or `formatUsernameCustom` as `name`. + * Use `formatLogin`/`formatSite` options to customise how those segments appear. + * @example + * const name = formatUsername(userInfo, 'lf.m.'); + * buildUsernameText({ name, login: userInfo.login, site: userInfo.site }); + * // → "Root M. A. mroot (corp)" + * @example + * // Strip parentheses from site (useful for search/filter comparisons) + * buildUsernameText({ name, login, site }, { formatSite: (s) => s }); + * // → "Root M. A. mroot corp" + */ +export function buildUsernameText( + data: { name: string; login?: string; site?: string }, + options?: BuildUsernameTextOptions +): string { + const formatLogin = options?.formatLogin ?? ((login) => login); + const formatSite = options?.formatSite ?? ((site) => `(${site})`); + + return [ + data.name, + data.login && formatLogin(data.login), + data.site && formatSite(data.site), + ] + .filter(Boolean) + .join(' '); +} diff --git a/packages/components/src/components/index.ts b/packages/components/src/components/index.ts index 9837688b..1a346b44 100644 --- a/packages/components/src/components/index.ts +++ b/packages/components/src/components/index.ts @@ -56,6 +56,7 @@ export * from './TreeSelect'; export * from './EmptyState'; export * from './Flag'; export * from './Sidebar'; +export * from './Username'; export * from './layout'; export { useListData, diff --git a/tools/api-extractor/config.json b/tools/api-extractor/config.json index 205f5c09..5c0e2cb4 100644 --- a/tools/api-extractor/config.json +++ b/tools/api-extractor/config.json @@ -57,7 +57,8 @@ "Tooltip", "Tree", "TreeSelect", - "Typography" + "Typography", + "Username" ], "packages": [ { "dir": "primitives", "report": "react-primitives" }, 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 00000000..bb667b30 --- /dev/null +++ b/tools/public_api_guard/components/Username.api.md @@ -0,0 +1,94 @@ +## API Report File for "koobiq-react" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { ComponentPropsWithRef } from 'react'; +import { DetailedHTMLProps } from 'react'; +import { ForwardRefExoticComponent } from 'react'; +import { HTMLAttributes } from 'react'; +import type { ReactNode } from 'react'; +import { RefAttributes } from 'react'; + +// @public +export function buildUsernameText(data: { + name: string; + login?: string; + site?: string; +}, options?: BuildUsernameTextOptions): string; + +// @public (undocumented) +export type BuildUsernameTextOptions = { + formatLogin?: (login: string) => string; + formatSite?: (site: string) => string; +}; + +// @public +export function formatUsername(userInfo: UsernameUserInfo | undefined, format?: string): string; + +// @public +export function formatUsernameCustom(userInfo: UsernameUserInfo | undefined, format?: string, mapping?: UsernameFormatMapping): string; + +// @public +export const Username: ForwardRefExoticComponent & RefAttributes>; + +// @public (undocumented) +export type UsernameBaseProps = Omit, 'children'> & { + userInfo?: UsernameUserInfo; + mode?: UsernamePropMode; + type?: UsernamePropType; + isCompact?: boolean; + fullNameFormat?: string; + formatter?: (userInfo: UsernameUserInfo | undefined, format: string) => string; + children?: ReactNode; +}; + +// @public +export type UsernameFormatMapping = Record; + +// @public (undocumented) +export const UsernamePrimary: ForwardRefExoticComponent, HTMLSpanElement>, "ref"> & RefAttributes>; + +// @public (undocumented) +export type UsernamePrimaryProps = ComponentPropsWithRef<'span'>; + +// @public (undocumented) +export type UsernamePropMode = (typeof usernamePropMode)[number]; + +// @public (undocumented) +export const usernamePropMode: readonly ["stacked", "inline", "text"]; + +// @public (undocumented) +export type UsernameProps = ComponentPropsWithRef; + +// @public (undocumented) +export type UsernamePropType = (typeof usernamePropType)[number]; + +// @public (undocumented) +export const usernamePropType: readonly ["default", "error", "accented", "inherit"]; + +// @public (undocumented) +export const UsernameSecondary: ForwardRefExoticComponent, HTMLSpanElement>, "ref"> & RefAttributes>; + +// @public (undocumented) +export const UsernameSecondaryHint: ForwardRefExoticComponent, HTMLSpanElement>, "ref"> & RefAttributes>; + +// @public (undocumented) +export type UsernameSecondaryHintProps = ComponentPropsWithRef<'span'>; + +// @public (undocumented) +export type UsernameSecondaryProps = ComponentPropsWithRef<'span'>; + +// @public (undocumented) +export type UsernameUserInfo = { + firstName?: string; + lastName?: string; + middleName?: string; + login?: string; + site?: string; +}; + +// (No @packageDocumentation comment for this package) + +```