diff --git a/.github/workflows/api.yml b/.github/workflows/api.yml index ebf63e6182..73825e991b 100644 --- a/.github/workflows/api.yml +++ b/.github/workflows/api.yml @@ -17,3 +17,4 @@ jobs: - uses: ./.github/workflows/actions/setup-node - uses: ./.github/workflows/actions/build-packages - run: yarn run check-api + - run: yarn run check-public-api-any diff --git a/apps/docs/src/app/structure.ts b/apps/docs/src/app/structure.ts index d4616e4b08..75b31b1e74 100644 --- a/apps/docs/src/app/structure.ts +++ b/apps/docs/src/app/structure.ts @@ -6,6 +6,7 @@ export enum DocsStructureItemId { Installation = 'installation', Versioning = 'versioning', Theming = 'theming', + Localization = 'localization', Typography = 'typography', DesignTokens = 'design-tokens', Schematics = 'schematics', @@ -225,6 +226,17 @@ const structure: DocsStructure = makeStructure({ hasApi: false, hasExamples: false }, + { + id: DocsStructureItemId.Localization, + name: { + ru: 'Локализация', + en: 'Localization' + }, + svgPreview: '', + isGuide: true, + hasApi: false, + hasExamples: false + }, { id: DocsStructureItemId.Typography, name: { diff --git a/docs/guides/localization.en.md b/docs/guides/localization.en.md new file mode 100644 index 0000000000..9ed133331c --- /dev/null +++ b/docs/guides/localization.en.md @@ -0,0 +1,123 @@ +## Localization + +Koobiq components render some strings of their own — the filters menu of the filter bar, the tooltips of +the code block, the accessible names of every icon-only button, the placeholder of a datepicker, and so on. +All of them come from one place: `KbqLocaleService`. + +Your own data is never translated. Option labels, filter values, table cells and everything else you pass +in stays exactly as you wrote it. + +The library ships five locales: `en-US`, `es-LA`, `pt-BR`, `ru-RU` and `tk-TM`. + +### Setting the locale + +`KbqLocaleService` is `providedIn: 'root'`, but the components read it through the `KBQ_LOCALE_SERVICE` +token, which has no factory. Nothing is localized until you provide it: + +```ts +import { KBQ_LOCALE_SERVICE, KbqLocaleService } from '@koobiq/components/core'; + +bootstrapApplication(AppComponent, { + providers: [{ provide: KBQ_LOCALE_SERVICE, useClass: KbqLocaleService }] +}); +``` + +Without that provider every component falls back to its own built-in `ru-RU` defaults, and switching the +locale at runtime does nothing. + +There are three ways to control which locale is active: + +- **`KBQ_DEFAULT_LOCALE_ID`** is the fallback, `ru-RU`. It is a plain exported constant, not an injection + token — it cannot be provided, only read. +- **`KBQ_LOCALE_ID`** fixes the locale once, when `KbqLocaleService` is constructed. It must sit in the + **same `providers` array** as the service itself, because the service reads the token from the injector + that created it. +- **`setLocale(id)`** changes the locale at runtime. + +```ts +providers: [ + { provide: KBQ_LOCALE_ID, useValue: 'en-US' }, + { provide: KBQ_LOCALE_SERVICE, useClass: KbqLocaleService } +]; +``` + +Reading the active locale: + +```ts +readonly localeService = inject(KBQ_LOCALE_SERVICE); + +readonly currentLocale = this.localeService.localeId; // Signal +readonly localeData = this.localeService.data; // Signal +readonly available = this.localeService.items; // Signal, for a locale picker +``` + +`changes` (a `BehaviorSubject`), `id` and `current` still work and stay in sync. Prefer the signals in new +code: a signal read from a template registers on the reading view, so a runtime `setLocale()` reaches +`OnPush` children that an observable subscribed in the parent could not. + +### Overriding the strings of one component + +Every localized component exposes a configuration token and a matching provider. Only the keys you pass are +overridden — everything else keeps its default: + +```ts +import { kbqCodeBlockLocaleConfigurationProvider } from '@koobiq/components/code-block'; + +providers: [kbqCodeBlockLocaleConfigurationProvider({ copyTooltip: 'Copy the snippet' })]; +``` + +Because these providers are element-injector friendly, providing one on a component scopes the override to +that component's subtree. + +Each helper ships from its own component's package. The exception is `kbqSelectLocaleConfigurationProvider`, +which ships from `@koobiq/components/core`: the `select` section is rendered by three packages that do not +depend on one another — `kbq-select`, `kbq-tree-select` and `kbq-tree-selection`. + +An override is applied on top of whatever is active — the locale service when the application provides one, +the token's defaults otherwise. So the keys you pass stay pinned across a runtime `setLocale()`, while every +key you did not pass follows the locale. Override a whole section if you want it to stop following the +locale entirely; register your own locale (see below) if you want the override to switch along with the +others. + +### Registering your own locale + +`addLocale()` accepts partial data — every section, and every key within a section, is optional. Whatever +you leave out is completed from the shipped locale of the same id, or from `KBQ_DEFAULT_LOCALE_ID` when the +id is new. `getParams()` therefore always returns a complete section, whatever you registered: + +```ts +localeService.addLocale('en-GB', { + select: { selectAll: 'Select everything' }, + a11y: { close: 'Dismiss' } +}); +``` + +The same shape can be provided up front through `KBQ_LOCALE_DATA`: + +```ts +{ provide: KBQ_LOCALE_DATA, useValue: { 'en-GB': { select: { selectAll: 'Select everything' } } } } +``` + +`KbqLocaleData` is the full contract, so a misspelled section or key is a compile error rather than a +string that silently never appears. + +### Reading a section yourself + +```ts +const { selectAll } = localeService.getParams('select'); // KbqSelectLocaleConfiguration +const select = localeService.params('select'); // Signal +``` + +The section name is checked against `KbqLocaleSection`, and the return type follows from it. + +### Dates and numbers + +Date adapters and the number pipes follow the same service, but they need their own providers. Note that +`KbqLocaleServiceModule` — pulled in by the date adapter modules — registers `KBQ_LOCALE_SERVICE` with +`useClass`, which builds a **second instance**, independent of the `providedIn: 'root'` one. If you switch +the locale on one and read it on the other, nothing happens. Always inject the `KBQ_LOCALE_SERVICE` token, +never the `KbqLocaleService` class. + +To scope a locale to a subtree that contains dates, provide the adapter and formatter in that same +`providers` array — `imports: [KbqLuxonDateModule]` puts them in the environment injector, where they +resolve the root locale service and render month names in the wrong language. diff --git a/docs/guides/localization.ru.md b/docs/guides/localization.ru.md new file mode 100644 index 0000000000..41e421bf8f --- /dev/null +++ b/docs/guides/localization.ru.md @@ -0,0 +1,124 @@ +## Локализация + +Компоненты Koobiq выводят собственные строки — меню фильтров в filter bar, подсказки в code block, +доступные имена всех кнопок-иконок, плейсхолдер поля даты и так далее. Все они приходят из одного места: +`KbqLocaleService`. + +Ваши данные не переводятся. Названия опций, значения фильтров, ячейки таблиц и всё остальное, что вы +передаёте в компонент, остаётся ровно таким, как вы его написали. + +Доступные идентификаторы локали: `en-US`, `es-LA`, `pt-BR`, `ru-RU` и `tk-TM`. + +### Подключение локали + +`KbqLocaleService` объявлен как `providedIn: 'root'`, но компоненты читают его через токен +`KBQ_LOCALE_SERVICE`, у которого нет фабрики. Пока вы не предоставите его, локализация не работает: + +```ts +import { KBQ_LOCALE_SERVICE, KbqLocaleService } from '@koobiq/components/core'; + +bootstrapApplication(AppComponent, { + providers: [{ provide: KBQ_LOCALE_SERVICE, useClass: KbqLocaleService }] +}); +``` + +Без этого провайдера каждый компонент использует собственные значения по умолчанию (`ru-RU`), а смена +локали во время работы приложения ничего не меняет. + +Управлять активной локалью можно тремя способами: + +- **`KBQ_DEFAULT_LOCALE_ID`** — значение по умолчанию, `ru-RU`. Это обычная экспортируемая константа, а не + injection token: её нельзя предоставить, только прочитать. +- **`KBQ_LOCALE_ID`** фиксирует локаль один раз, в момент создания `KbqLocaleService`. Токен должен лежать + в **том же массиве `providers`**, что и сам сервис, потому что сервис читает его из создавшего инжектора. +- **`setLocale(id)`** меняет локаль во время работы приложения. + +```ts +providers: [ + { provide: KBQ_LOCALE_ID, useValue: 'en-US' }, + { provide: KBQ_LOCALE_SERVICE, useClass: KbqLocaleService } +]; +``` + +Чтение активной локали: + +```ts +readonly localeService = inject(KBQ_LOCALE_SERVICE); + +readonly currentLocale = this.localeService.localeId; // Signal +readonly localeData = this.localeService.data; // Signal +readonly available = this.localeService.items; // Signal, для выбора локали +``` + +`changes` (`BehaviorSubject`), `id` и `current` продолжают работать и синхронизированы с сигналами. В новом +коде используйте сигналы: чтение сигнала в шаблоне регистрируется на читающем представлении, поэтому +`setLocale()` во время работы приложения доходит до `OnPush`-потомков, чего подписка в родителе сделать +не может. + +### Переопределение строк одного компонента + +У каждого локализованного компонента есть токен конфигурации и соответствующий провайдер. Переопределяются +только переданные ключи — остальные сохраняют значения по умолчанию: + +```ts +import { kbqCodeBlockLocaleConfigurationProvider } from '@koobiq/components/code-block'; + +providers: [kbqCodeBlockLocaleConfigurationProvider({ copyTooltip: 'Скопировать фрагмент' })]; +``` + +Эти провайдеры работают и в element injector, поэтому провайдер на компоненте ограничивает переопределение +его поддеревом. + +Каждая такая функция поставляется из пакета своего компонента. Исключение — +`kbqSelectLocaleConfigurationProvider`, который живёт в `@koobiq/components/core`: секцию `select` выводят +три независимых друг от друга пакета — `kbq-select`, `kbq-tree-select` и `kbq-tree-selection`. + +Переопределение накладывается поверх того, что активно: поверх сервиса локали, если приложение его +предоставляет, иначе поверх значений по умолчанию из токена. Поэтому переданные вами ключи остаются +закреплёнными при вызове `setLocale()` во время работы, а все остальные следуют за локалью. Переопределите +секцию целиком, если она вообще не должна следовать за локалью, либо зарегистрируйте собственную локаль +(см. ниже), если переопределение должно переключаться вместе с остальными строками. + +### Регистрация собственной локали + +`addLocale()` принимает частичные данные — каждая секция и каждый ключ внутри секции необязательны. Всё, +что вы не указали, дополняется из поставляемой локали с тем же идентификатором, а для нового +идентификатора — из `KBQ_DEFAULT_LOCALE_ID`. Поэтому `getParams()` всегда возвращает полную секцию, +что бы вы ни зарегистрировали: + +```ts +localeService.addLocale('en-GB', { + select: { selectAll: 'Select everything' }, + a11y: { close: 'Dismiss' } +}); +``` + +Те же данные можно передать заранее через `KBQ_LOCALE_DATA`: + +```ts +{ provide: KBQ_LOCALE_DATA, useValue: { 'en-GB': { select: { selectAll: 'Select everything' } } } } +``` + +Полный контракт описан типом `KbqLocaleData`, поэтому опечатка в названии секции или ключа — это ошибка +компиляции, а не строка, которая молча никогда не появится. + +### Чтение секции напрямую + +```ts +const { selectAll } = localeService.getParams('select'); // KbqSelectLocaleConfiguration +const select = localeService.params('select'); // Signal +``` + +Название секции проверяется по `KbqLocaleSection`, а тип результата выводится из него. + +### Даты и числа + +Адаптеры дат и числовые пайпы используют тот же сервис, но им нужны собственные провайдеры. Учтите, что +`KbqLocaleServiceModule` — его подключают модули адаптеров дат — регистрирует `KBQ_LOCALE_SERVICE` через +`useClass`, а значит создаёт **второй экземпляр**, независимый от `providedIn: 'root'`. Если менять локаль +на одном, а читать с другого, ничего не произойдёт. Всегда инжектируйте токен `KBQ_LOCALE_SERVICE`, +а не класс `KbqLocaleService`. + +Чтобы ограничить локаль поддеревом, в котором есть даты, объявите адаптер и форматтер в том же массиве +`providers`: `imports: [KbqLuxonDateModule]` помещает их в environment injector, где они получат корневой +сервис локали и выведут названия месяцев на другом языке. diff --git a/docs/guides/migration.en.md b/docs/guides/migration.en.md index 945be53262..6de7f94770 100644 --- a/docs/guides/migration.en.md +++ b/docs/guides/migration.en.md @@ -19,6 +19,7 @@ New versions include improvements but also contain **breaking changes**; they mu 13. **20.3.0**: the theme service review — signals, `auto` mode and built-in persistence. 14. **20.3.0**: explicit prefix and suffix slots for tag content. 15. **20.3.0**: deprecation of the overlayscrollbars-based Scrollbar implementation. +16. **20.3.0**: the locale layer typing — a typed `getParams`, partial locale data and signals. ### 1. Upgrade to 18.5.3 @@ -874,6 +875,86 @@ import { KbqScrollbarModule } from '@koobiq/components/scrollbar/deprecated'; **Do not import both the old and the new implementation into the same standalone component.** Both use the `kbq-scrollbar` element selector, so Angular cannot choose a component unambiguously. During a gradual manual migration, keep old and new usage in separate components. +### 16. Locale layer typing (20.3.0) + +The locale layer is fully typed now, and every localized component takes its strings through one shared +mechanism. Nothing was removed and no signature was narrowed in a way that rejects code which used to +compile — this section is here so you know what became possible, and which two narrowed types could surface a +latent mistake in your own code. + +**`getParams()` resolves the section type.** A known section name returns its configuration type instead of +`any`; a dynamically-built string still returns `any`, so existing call sites keep working. + +```ts +const { selectAll } = localeService.getParams('select'); // KbqSelectLocaleConfiguration +localeService.getParams('selection'); // not a section - now a compile error +``` + +**Custom locale data may be partial.** `addLocale()` and `KBQ_LOCALE_DATA` accept any subset of +`KbqLocaleData` and complete it from the shipped locale of the same id, or from `KBQ_DEFAULT_LOCALE_ID` for +a new id. You no longer have to restate a whole locale to change one string, and a section you leave out +can no longer surface as `undefined` at runtime. The two earlier notes about custom locale data needing an +`a11y` section no longer apply — a missing section is filled in for you. + +**Signals alongside the observable.** `localeId()`, `data()` and `items()` join `changes`, and +`params(section)` returns a `Signal` of one section. `changes` keeps working; `id` and `current` are +deprecated in favour of `localeId()` and `data()`. Prefer the signals: a signal read from a template +registers on the reading view, so a runtime `setLocale()` reaches `OnPush` children that a subscription in +the parent never marked dirty. + +**Configuration providers accept a partial, and now apply on top of the active locale.** +`kbqA11yLocaleConfigurationProvider`, `kbqCodeBlockLocaleConfigurationProvider`, +`kbqClampedTextLocaleConfigurationProvider`, `kbqActionsPanelLocaleConfigurationProvider` and +`kbqTimeRangeLocaleConfigurationProvider` now take only the keys you want to change. Previously the locale +service took precedence over them, so an application that provided `KBQ_LOCALE_SERVICE` saw these providers +ignored entirely; the keys you pass are now merged over the active locale and stay pinned across a runtime +`setLocale()`, while the keys you leave out keep following it. Passing a full object still works and pins +the whole section. + +**Component configuration tokens now supply defaults, not overrides.** `KBQ_VERTICAL_NAVBAR_CONFIGURATION`, +`KBQ_NOTIFICATION_CENTER_CONFIGURATION`, `KBQ_APP_SWITCHER_CONFIGURATION`, +`KBQ_SEARCH_EXPANDABLE_CONFIGURATION`, `KBQ_DATEPICKER_CONFIGURATION` and `KBQ_FILTER_BAR_CONFIGURATION` used +to beat the locale service outright. Every one of those components now reads the shared +`kbqInjectLocaleConfiguration` helper, where the token carries the defaults and the active locale wins, so +`{ provide: KBQ__CONFIGURATION, useValue: … }` is silently ignored in any application that provides +`KBQ_LOCALE_SERVICE`. Replace it with the matching `kbqLocaleConfigurationProvider(…)`, which registers a +real override — `ng update` rewrites it for you. The same conversion dropped the `externalConfiguration` +member from those components and made `configuration` read-only, and gave `kbq-select`, `kbq-tree-select`, +`kbq-tree-selection`, `kbq-timepicker`, `kbq-timezone-select` and the number input the token-and-provider +pair they never had. One behaviour fix rides along: an explicit `[hiddenItemsText]` binding on `kbq-select` +and `kbq-tree-select` is no longer wiped by the next `setLocale()`. + +**Type names were normalized to `KbqLocaleConfiguration`.** The old names — `KbqAppSwitcherConfiguration`, +`KbqClampedTextLocaleConfig`, `KbqTimeRangeLocaleConfig`, `KbqNumberInputLocaleConfig`, +`KbqNumberRoundingLocaleConfig`, `KbqFileUploadLocaleConfig`, `KbqBaseFileUploadLocaleConfig` and +`KbqMultipleFileUploadLocaleConfig` — remain as deprecated aliases. Likewise +`kbqInjectKbqClampedLocaleConfiguration` is now `kbqInjectClampedTextLocaleConfiguration`, with the old name +kept. + +**Two narrowed types worth checking.** `KBQ_DATEPICKER_CONFIGURATION`, `KBQ_VERTICAL_NAVBAR_CONFIGURATION`, +`KBQ_NOTIFICATION_CENTER_CONFIGURATION` and `KBQ_SEARCH_EXPANDABLE_CONFIGURATION` used to be +`InjectionToken` and now carry their real type, so a value you provide for one of them is +type-checked for the first time. And `defaultUnitSystem` on the exported `*FormattersData` constants is now +the literal `'SI'` rather than `string`; only code that assigns to it is affected. + +#### Running the migration + +The `locale-configuration-providers` schematic rewrites the configuration providers automatically: + +```bash +ng update @koobiq/components@20 +``` + +Or manually: + +```bash +ng g @koobiq/components:locale-configuration-providers --project +``` + +Run it even if you upgrade by hand: a `{ provide: KBQ__CONFIGURATION, useValue: … }` left behind is +silently ignored at runtime rather than reported as a compile error. The rest of this section — the renamed +types and the two narrowed ones — surfaces as compile errors whose messages already name the fix. + ### After the migration After fully moving to the new component and removing imports from `@koobiq/components/scrollbar/deprecated`, the `overlayscrollbars` dependency is no longer needed and can be removed: diff --git a/docs/guides/migration.ru.md b/docs/guides/migration.ru.md index 635fa77df9..f80a27a5fc 100644 --- a/docs/guides/migration.ru.md +++ b/docs/guides/migration.ru.md @@ -19,6 +19,7 @@ 13. **20.3.0**: ревью сервиса темизации — сигналы, режим `auto` и сохранение выбора из коробки. 14. **20.3.0**: явные prefix- и suffix-слоты для содержимого тегов. 15. **20.3.0**: устаревание overlayscrollbars-реализации Scrollbar. +16. **20.3.0**: типизация слоя локализации — типизированный `getParams`, частичные данные локали и сигналы. ### 1. Обновление до 18.5.3 @@ -874,6 +875,90 @@ import { KbqScrollbarModule } from '@koobiq/components/scrollbar/deprecated'; **Не импортируйте старую и новую реализацию в одном standalone-компоненте одновременно.** Обе используют элементный селектор `kbq-scrollbar`, поэтому Angular не сможет однозначно выбрать компонент. При постепенном ручном переходе держите старое и новое использование в разных компонентах. +### 16. Типизация слоя локализации (20.3.0) + +Слой локализации полностью типизирован, а строки всех локализованных компонентов проходят через один общий +механизм. Ничего не удалено, и ни одна сигнатура не сужена так, чтобы отвергнуть ранее компилировавшийся +код — раздел нужен, чтобы вы знали, что стало возможно и какие два сужения могут вскрыть уже существующую +ошибку в вашем коде. + +**`getParams()` выводит тип секции.** Известное название секции возвращает её тип конфигурации вместо +`any`; строка, собранная динамически, по-прежнему возвращает `any`, поэтому существующие вызовы продолжают +работать. + +```ts +const { selectAll } = localeService.getParams('select'); // KbqSelectLocaleConfiguration +localeService.getParams('selection'); // не секция - теперь ошибка компиляции +``` + +**Свои данные локали могут быть частичными.** `addLocale()` и `KBQ_LOCALE_DATA` принимают любое подмножество +`KbqLocaleData` и дополняют его из поставляемой локали с тем же идентификатором, а для нового +идентификатора — из `KBQ_DEFAULT_LOCALE_ID`. Больше не нужно повторять всю локаль ради одной строки, а +пропущенная секция не может появиться как `undefined` во время работы. Две прежние заметки о том, что своим +данным локали нужна секция `a11y`, больше неактуальны — недостающая секция подставляется автоматически. + +**Сигналы рядом с observable.** К `changes` добавились `localeId()`, `data()` и `items()`, а +`params(section)` возвращает `Signal` одной секции. `changes` продолжает работать; `id` и `current` +объявлены устаревшими в пользу `localeId()` и `data()`. Используйте сигналы: чтение сигнала в шаблоне +регистрируется на читающем представлении, поэтому `setLocale()` доходит до `OnPush`-потомков, которые +подписка в родителе никогда не помечала как изменённые. + +**Провайдеры конфигурации принимают частичный объект и теперь применяются поверх активной локали.** +`kbqA11yLocaleConfigurationProvider`, `kbqCodeBlockLocaleConfigurationProvider`, +`kbqClampedTextLocaleConfigurationProvider`, `kbqActionsPanelLocaleConfigurationProvider` и +`kbqTimeRangeLocaleConfigurationProvider` теперь принимают только те ключи, которые вы хотите изменить. +Раньше сервис локали имел приоритет над ними, поэтому в приложении, предоставляющем `KBQ_LOCALE_SERVICE`, +эти провайдеры игнорировались полностью; теперь переданные ключи накладываются на активную локаль и остаются +закреплёнными при вызове `setLocale()` во время работы, а не переданные — продолжают следовать за локалью. +Передача полного объекта по-прежнему работает и закрепляет секцию целиком. + +**Токены конфигурации компонентов задают значения по умолчанию, а не переопределение.** +`KBQ_VERTICAL_NAVBAR_CONFIGURATION`, `KBQ_NOTIFICATION_CENTER_CONFIGURATION`, +`KBQ_APP_SWITCHER_CONFIGURATION`, `KBQ_SEARCH_EXPANDABLE_CONFIGURATION`, `KBQ_DATEPICKER_CONFIGURATION` и +`KBQ_FILTER_BAR_CONFIGURATION` раньше побеждали сервис локали. Теперь все эти компоненты читают общую +функцию `kbqInjectLocaleConfiguration`, где токен несёт значения по умолчанию, а побеждает активная локаль, +поэтому +`{ provide: KBQ__CONFIGURATION, useValue: … }` молча игнорируется в приложении, предоставляющем +`KBQ_LOCALE_SERVICE`. Замените его на соответствующий `kbqLocaleConfigurationProvider(…)`, который +регистрирует настоящее переопределение, — `ng update` перепишет это за вас. Та же конверсия убрала из этих +компонентов член `externalConfiguration` и сделала `configuration` доступным только для чтения, а +`kbq-select`, `kbq-tree-select`, `kbq-tree-selection`, `kbq-timepicker`, `kbq-timezone-select` и числовой +инпут получили пару «токен и провайдер», которой у них не было. Попутно исправлено поведение: явная привязка +`[hiddenItemsText]` у `kbq-select` и `kbq-tree-select` больше не затирается следующим `setLocale()`. + +**Названия типов приведены к виду `KbqLocaleConfiguration`.** Прежние имена — +`KbqAppSwitcherConfiguration`, `KbqClampedTextLocaleConfig`, `KbqTimeRangeLocaleConfig`, +`KbqNumberInputLocaleConfig`, `KbqNumberRoundingLocaleConfig`, `KbqFileUploadLocaleConfig`, +`KbqBaseFileUploadLocaleConfig` и `KbqMultipleFileUploadLocaleConfig` — сохранены как устаревшие +псевдонимы. Так же `kbqInjectKbqClampedLocaleConfiguration` стал `kbqInjectClampedTextLocaleConfiguration`, +старое имя сохранено. + +**Два сужения, которые стоит проверить.** `KBQ_DATEPICKER_CONFIGURATION`, +`KBQ_VERTICAL_NAVBAR_CONFIGURATION`, `KBQ_NOTIFICATION_CENTER_CONFIGURATION` и +`KBQ_SEARCH_EXPANDABLE_CONFIGURATION` были `InjectionToken`, а теперь несут свой настоящий тип, +поэтому предоставляемое для них значение впервые проверяется типами. А `defaultUnitSystem` в экспортируемых +константах `*FormattersData` теперь литерал `'SI'`, а не `string`; это затрагивает только код, который в +него присваивает. + +#### Запуск миграции + +Схематик `locale-configuration-providers` переписывает провайдеры конфигурации автоматически: + +```bash +ng update @koobiq/components@20 +``` + +Или вручную: + +```bash +ng g @koobiq/components:locale-configuration-providers --project +``` + +Запустите его, даже если обновляетесь вручную: оставшийся `{ provide: KBQ__CONFIGURATION, useValue: … }` +молча игнорируется во время работы, а не сообщается как ошибка компиляции. Остальная часть этого раздела — +переименованные типы и два сужения — проявляется ошибками компиляции, сообщения которых сами называют +исправление. + ### После миграции После полного перехода на новый компонент и удаления импортов из `@koobiq/components/scrollbar/deprecated` зависимость `overlayscrollbars` больше не нужна — её можно удалить: diff --git a/package.json b/package.json index 7cf791219e..8f2cd3d832 100644 --- a/package.json +++ b/package.json @@ -303,6 +303,8 @@ "-----API-----": "--------------------------------------------------------------------------------------------", "approve-api": "ts-node --project tools/api-extractor/tsconfig.json tools/api-extractor/api-extractor.ts", "check-api": "yarn run approve-api onlyCheck", + "check-public-api-any": "ts-node --project tools/check-public-api-any/tsconfig.json tools/check-public-api-any", + "approve-public-api-any": "ts-node --project tools/check-public-api-any/tsconfig.json tools/check-public-api-any --approve", "-----LINTERS-----": "----------------------------------------------------------------------------------------", "check-peer-deps": "ts-node --project tools/check-peer-deps/tsconfig.json tools/check-peer-deps", "check-npm-resolution": "ts-node --project tools/check-npm-resolution/tsconfig.json tools/check-npm-resolution", diff --git a/packages/angular-luxon-adapter/adapter/date-adapter.ts b/packages/angular-luxon-adapter/adapter/date-adapter.ts index 7c67f44187..6dfb08eb8e 100644 --- a/packages/angular-luxon-adapter/adapter/date-adapter.ts +++ b/packages/angular-luxon-adapter/adapter/date-adapter.ts @@ -1,5 +1,6 @@ import { getLocaleFirstDayOfWeek } from '@angular/common'; import { Injectable, InjectionToken, inject } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { KBQ_DATE_LOCALE, KBQ_DEFAULT_LOCALE_ID, KBQ_LOCALE_SERVICE, KbqLocaleService } from '@koobiq/components/core'; import { LuxonDateAdapter as BaseLuxonDateAdapter, LuxonDateAdapterOptions } from '@koobiq/luxon-date-adapter'; import { Info } from 'luxon'; @@ -43,7 +44,7 @@ export class LuxonDateAdapter extends BaseLuxonDateAdapter { this.setLocale(this.localeService?.id || dateLocale); - this.localeService?.changes.subscribe(this.setLocale); + this.localeService?.changes.pipe(takeUntilDestroyed()).subscribe(this.setLocale); } setLocale = (locale: string): void => { diff --git a/packages/angular-moment-adapter/adapter/moment-date-adapter.ts b/packages/angular-moment-adapter/adapter/moment-date-adapter.ts index 024177dc99..9a1ee87f7a 100644 --- a/packages/angular-moment-adapter/adapter/moment-date-adapter.ts +++ b/packages/angular-moment-adapter/adapter/moment-date-adapter.ts @@ -1,4 +1,5 @@ import { Injectable, InjectionToken, inject } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { KBQ_DATE_LOCALE, KBQ_LOCALE_SERVICE, KbqLocaleService } from '@koobiq/components/core'; import { MomentDateAdapter as BaseMomentDateAdapter, MomentDateAdapterOptions } from '@koobiq/moment-date-adapter'; import { Observable, Subject } from 'rxjs'; @@ -37,7 +38,7 @@ export class MomentDateAdapter extends BaseMomentDateAdapter { this.setLocale(this.localeService?.id || dateLocale); - this.localeService?.changes.subscribe(this.setLocale); + this.localeService?.changes.pipe(takeUntilDestroyed()).subscribe(this.setLocale); } /** A stream that emits when the locale changes. */ diff --git a/packages/components/actions-panel/actions-panel-container.ts b/packages/components/actions-panel/actions-panel-container.ts index 2533356c56..45e4fde93b 100644 --- a/packages/components/actions-panel/actions-panel-container.ts +++ b/packages/components/actions-panel/actions-panel-container.ts @@ -22,19 +22,19 @@ import { Renderer2, ViewEncapsulation } from '@angular/core'; -import { toSignal } from '@angular/core/rxjs-interop'; import { KbqButtonModule } from '@koobiq/components/button'; import { - KBQ_LOCALE_SERVICE, KbqActionsPanelLocaleConfiguration, KbqAnimationCurves, KbqAnimationDurations, + KbqDeepPartial, + kbqInjectLocaleConfiguration, + kbqLocaleConfigurationOverrideProvider, ruRULocaleData } from '@koobiq/components/core'; import { KbqDividerModule } from '@koobiq/components/divider'; import { KbqIconModule } from '@koobiq/components/icon'; import { KbqToolTipModule } from '@koobiq/components/tooltip'; -import { map, of } from 'rxjs'; import { KbqActionsPanel } from './actions-panel'; import { KbqActionsPanelConfig } from './actions-panel-config'; @@ -44,13 +44,13 @@ export const KBQ_ACTIONS_PANEL_LOCALE_CONFIGURATION = new InjectionToken ruRULocaleData.actionsPanel } ); -/** Utility provider for `KBQ_ACTIONS_PANEL_LOCALE_CONFIGURATION`. */ +/** + * Utility provider for `KBQ_ACTIONS_PANEL_LOCALE_CONFIGURATION`. Only the strings you pass are + * overridden; the rest keep following the active locale. + */ export const kbqActionsPanelLocaleConfigurationProvider = ( - configuration: KbqActionsPanelLocaleConfiguration -): Provider => ({ - provide: KBQ_ACTIONS_PANEL_LOCALE_CONFIGURATION, - useValue: configuration -}); + configuration: KbqDeepPartial +): Provider => kbqLocaleConfigurationOverrideProvider('actionsPanel', configuration); /** * Animation that shows and hides the actions panel. @@ -98,8 +98,8 @@ const KBQ_ACTIONS_PANEL_CONTAINER_ANIMATION = trigger('state', [ class="kbq-actions-panel-container__close-button" color="contrast" kbq-button - [attr.aria-label]="localeConfiguration()!.closeTooltip" - [kbqTooltip]="localeConfiguration()!.closeTooltip" + [attr.aria-label]="localeConfiguration().closeTooltip" + [kbqTooltip]="localeConfiguration().closeTooltip" [kbqTooltipOffset]="16" (click)="close()" > @@ -152,17 +152,14 @@ export class KbqActionsPanelContainer extends CdkDialogContainer implements OnDe private readonly actionsPanel = inject(KbqActionsPanel); private readonly renderer = inject(Renderer2); - private readonly localeService = inject(KBQ_LOCALE_SERVICE, { optional: true }); - /** * Actions panel locale configuration. * * @docs-private */ - protected readonly localeConfiguration = toSignal( - this.localeService - ? this.localeService.changes.pipe(map(() => this.localeService!.getParams('actionsPanel'))) - : of(inject(KBQ_ACTIONS_PANEL_LOCALE_CONFIGURATION)) + protected readonly localeConfiguration = kbqInjectLocaleConfiguration( + 'actionsPanel', + KBQ_ACTIONS_PANEL_LOCALE_CONFIGURATION ); override ngOnDestroy() { diff --git a/packages/components/app-switcher/app-switcher.ts b/packages/components/app-switcher/app-switcher.ts index 5eb30d4cac..5bbe2f60c7 100644 --- a/packages/components/app-switcher/app-switcher.ts +++ b/packages/components/app-switcher/app-switcher.ts @@ -36,8 +36,8 @@ import { ENTER, ESCAPE, FocusKeyManager, - KBQ_LOCALE_SERVICE, - KbqAppSwitcherConfiguration, + KbqAppSwitcherLocaleConfiguration, + KbqDeepPartial, KbqOptionModule, KbqPopUp, KbqPopUpPlacementValues, @@ -53,6 +53,8 @@ import { TAB, UP_ARROW, applyPopupMargins, + kbqInjectLocaleConfiguration, + kbqLocaleConfigurationOverrideProvider, ruRULocaleData } from '@koobiq/components/core'; import { KbqDividerModule } from '@koobiq/components/divider'; @@ -205,14 +207,23 @@ export const KBQ_APP_SWITCHER_SCROLL_STRATEGY_FACTORY_PROVIDER = { /** default configuration of app-switcher */ /** @docs-private */ -export const KBQ_APP_SWITCHER_DEFAULT_CONFIGURATION: KbqAppSwitcherConfiguration = ruRULocaleData.appSwitcher; +export const KBQ_APP_SWITCHER_DEFAULT_CONFIGURATION: KbqAppSwitcherLocaleConfiguration = ruRULocaleData.appSwitcher; -/** Injection Token for providing configuration of app-switcher */ +/** Injection Token for providing the default configuration of app-switcher */ /** @docs-private */ -export const KBQ_APP_SWITCHER_CONFIGURATION = new InjectionToken( - 'KbqAppSwitcherConfiguration' +export const KBQ_APP_SWITCHER_CONFIGURATION = new InjectionToken( + 'KbqAppSwitcherConfiguration', + { factory: () => KBQ_APP_SWITCHER_DEFAULT_CONFIGURATION } ); +/** + * Utility provider for `KBQ_APP_SWITCHER_CONFIGURATION`. Only the strings you pass are overridden; the rest + * keep following the active locale. + */ +export const kbqAppSwitcherLocaleConfigurationProvider = ( + configuration: KbqDeepPartial +): Provider => kbqLocaleConfigurationOverrideProvider('appSwitcher', configuration); + /** * Providers used by the app-switcher. `KbqAppSwitcherModule` applies them for `NgModule` consumers; * standalone consumers that import `KbqAppSwitcherTrigger` directly may add them to their application (or @@ -263,18 +274,16 @@ export function kbqAppSwitcherProvider(): Provider[] { preserveWhitespaces: false }) export class KbqAppSwitcherComponent extends KbqPopUp implements AfterViewInit, OnDestroy { - /** @docs-private */ - protected readonly localeService = inject(KBQ_LOCALE_SERVICE, { optional: true }); - - /** Configuration provided through `KBQ_APP_SWITCHER_CONFIGURATION`, overriding the locale strings. */ - readonly externalConfiguration = inject(KBQ_APP_SWITCHER_CONFIGURATION, { optional: true }); - /** Strings currently rendered by the popup. */ - configuration: KbqAppSwitcherConfiguration = KBQ_APP_SWITCHER_DEFAULT_CONFIGURATION; + get configuration(): KbqAppSwitcherLocaleConfiguration { + return this._configuration(); + } + + private readonly _configuration = kbqInjectLocaleConfiguration('appSwitcher', KBQ_APP_SWITCHER_CONFIGURATION); /** localized data * @docs-private */ - get localeData(): KbqAppSwitcherConfiguration { + get localeData(): KbqAppSwitcherLocaleConfiguration { return this.configuration; } @@ -331,12 +340,6 @@ export class KbqAppSwitcherComponent extends KbqPopUp implements AfterViewInit, constructor() { super(); - - this.localeService?.changes.pipe(takeUntilDestroyed()).subscribe(this.updateLocaleParams); - - if (!this.localeService) { - this.initDefaultParams(); - } } ngAfterViewInit() { @@ -608,19 +611,6 @@ export class KbqAppSwitcherComponent extends KbqPopUp implements AfterViewInit, .map((site) => ({ ...site, apps: site.apps.filter(matches) })) .filter((site) => site.apps.length > 0); } - - private updateLocaleParams = () => { - this.configuration = - this.externalConfiguration || - (this.localeService?.getParams('appSwitcher') as KbqAppSwitcherConfiguration) || - KBQ_APP_SWITCHER_DEFAULT_CONFIGURATION; - - this.changeDetectorRef.markForCheck(); - }; - - private initDefaultParams() { - this.configuration = this.externalConfiguration || KBQ_APP_SWITCHER_DEFAULT_CONFIGURATION; - } } @Directive({ diff --git a/packages/components/clamped-text/clamped-list.ts b/packages/components/clamped-text/clamped-list.ts index 6e0ed68c05..46fc93d3e0 100644 --- a/packages/components/clamped-text/clamped-list.ts +++ b/packages/components/clamped-text/clamped-list.ts @@ -1,5 +1,5 @@ import { computed, Directive, inject, input, model, numberAttribute } from '@angular/core'; -import { KbqClamped, KbqClampedRoot, kbqInjectKbqClampedLocaleConfiguration } from './constants'; +import { KbqClamped, KbqClampedRoot, kbqInjectClampedTextLocaleConfiguration } from './constants'; @Directive({ selector: '[kbqClampedList]', @@ -42,7 +42,7 @@ export class KbqClampedList implements KbqClamped { ); /** Clamped text locale configuration. */ - readonly localeConfiguration = kbqInjectKbqClampedLocaleConfiguration(); + readonly localeConfiguration = kbqInjectClampedTextLocaleConfiguration(); /** Toggles the collapsed state of the list. Stops event propagation. */ toggle(event: Event) { diff --git a/packages/components/clamped-text/clamped-text.ts b/packages/components/clamped-text/clamped-text.ts index ed18e1c0f3..42d322ff1a 100644 --- a/packages/components/clamped-text/clamped-text.ts +++ b/packages/components/clamped-text/clamped-text.ts @@ -25,7 +25,7 @@ import { KbqClamped, KbqClampedRoot, kbqClampedTextDefaultMaxRows, - kbqInjectKbqClampedLocaleConfiguration + kbqInjectClampedTextLocaleConfiguration } from './constants'; @Component({ @@ -117,7 +117,7 @@ export class KbqClampedText implements KbqClamped, OnInit, AfterViewInit { * Clamped text locale configuration. * @docs-private */ - readonly localeConfiguration = kbqInjectKbqClampedLocaleConfiguration(); + readonly localeConfiguration = kbqInjectClampedTextLocaleConfiguration(); /** * This flag is used to prevent trigger resize observer on toggle click. diff --git a/packages/components/clamped-text/constants.ts b/packages/components/clamped-text/constants.ts index 7a27b1338d..17acce1d1b 100644 --- a/packages/components/clamped-text/constants.ts +++ b/packages/components/clamped-text/constants.ts @@ -1,7 +1,11 @@ -import { inject, InjectionToken, Provider, Signal } from '@angular/core'; -import { toSignal } from '@angular/core/rxjs-interop'; -import { KBQ_LOCALE_SERVICE, KbqClampedTextLocaleConfig, ruRULocaleData } from '@koobiq/components/core'; -import { map, of } from 'rxjs'; +import { InjectionToken, Provider, Signal } from '@angular/core'; +import { + KbqClampedTextLocaleConfiguration, + KbqDeepPartial, + kbqInjectLocaleConfiguration, + kbqLocaleConfigurationOverrideProvider, + ruRULocaleData +} from '@koobiq/components/core'; /** * Default maximum number of visible rows for the clamped text component @@ -10,7 +14,7 @@ import { map, of } from 'rxjs'; export const kbqClampedTextDefaultMaxRows = 5; /** Localization configuration provider. */ -export const KBQ_CLAMPED_TEXT_LOCALE_CONFIGURATION = new InjectionToken( +export const KBQ_CLAMPED_TEXT_LOCALE_CONFIGURATION = new InjectionToken( 'KbqClampedTextLocaleConfig', { factory: () => ruRULocaleData.clampedText @@ -18,13 +22,13 @@ export const KBQ_CLAMPED_TEXT_LOCALE_CONFIGURATION = new InjectionToken ({ - provide: KBQ_CLAMPED_TEXT_LOCALE_CONFIGURATION, - useValue: configuration -}); +export const kbqClampedTextLocaleConfigurationProvider = ( + configuration: KbqDeepPartial +): Provider => kbqLocaleConfigurationOverrideProvider('clampedText', configuration); export const KbqClampedRoot = new InjectionToken('KbqClampedRoot'); @@ -37,7 +41,7 @@ export interface KbqClamped { /** Whether the toggle trigger should be shown. */ hasToggle: Signal; /** Reactive locale strings for open/close labels. */ - localeConfiguration: Signal; + localeConfiguration: Signal; /** Toggles the collapsed state of the list. Stops event propagation. */ toggle(event: Event): void; } @@ -47,16 +51,12 @@ export interface KbqClamped { * @see {KbqClampedText, KbqClampedList} * @docs-private */ -export function kbqInjectKbqClampedLocaleConfiguration(): Signal { - const localeService = inject(KBQ_LOCALE_SERVICE, { optional: true }); - const initialValue = inject(KBQ_CLAMPED_TEXT_LOCALE_CONFIGURATION); - const config = localeService - ? localeService.changes.pipe( - map( - () => localeService.getParams('clampedText') satisfies KbqClampedTextLocaleConfig - ) - ) - : of(initialValue); - - return toSignal(config, { initialValue }); +export function kbqInjectClampedTextLocaleConfiguration(): Signal { + return kbqInjectLocaleConfiguration('clampedText', KBQ_CLAMPED_TEXT_LOCALE_CONFIGURATION); } + +/** + * @deprecated Use {@link kbqInjectClampedTextLocaleConfiguration}. + * @docs-private + */ +export const kbqInjectKbqClampedLocaleConfiguration = kbqInjectClampedTextLocaleConfiguration; diff --git a/packages/components/code-block/code-block.ts b/packages/components/code-block/code-block.ts index 4a08e217bf..25539940fb 100644 --- a/packages/components/code-block/code-block.ts +++ b/packages/components/code-block/code-block.ts @@ -34,10 +34,12 @@ import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; import { DomSanitizer } from '@angular/platform-browser'; import { KbqButtonModule, KbqButtonStyles } from '@koobiq/components/button'; import { - KBQ_LOCALE_SERVICE, KBQ_WINDOW, KbqCodeBlockLocaleConfiguration, KbqComponentColors, + KbqDeepPartial, + kbqInjectLocaleConfiguration, + kbqLocaleConfigurationOverrideProvider, KbqOverflowShadowContainer, KbqOverflowShadowTop, ruRULocaleData @@ -55,11 +57,13 @@ export const KBQ_CODE_BLOCK_LOCALE_CONFIGURATION = new InjectionToken ruRULocaleData.codeBlock } ); -/** Utility provider for `KBQ_CODE_BLOCK_LOCALE_CONFIGURATION`. */ -export const kbqCodeBlockLocaleConfigurationProvider = (configuration: KbqCodeBlockLocaleConfiguration): Provider => ({ - provide: KBQ_CODE_BLOCK_LOCALE_CONFIGURATION, - useValue: configuration -}); +/** + * Utility provider for `KBQ_CODE_BLOCK_LOCALE_CONFIGURATION`. Only the strings you pass are overridden; + * the rest keep following the active locale. + */ +export const kbqCodeBlockLocaleConfigurationProvider = ( + configuration: KbqDeepPartial +): Provider => kbqLocaleConfigurationOverrideProvider('codeBlock', configuration); /** Fallback file name for code block if file name is not specified. */ export const KBQ_CODE_BLOCK_FALLBACK_FILE_NAME = new InjectionToken('KBQ_CODE_BLOCK_FALLBACK_FILE_NAME', { @@ -308,10 +312,16 @@ export class KbqCodeBlock implements AfterViewInit { * @docs-private */ protected get localeConfiguration(): KbqCodeBlockLocaleConfiguration { - return this._localeConfiguration; + return this._localeConfiguration(); } - private _localeConfiguration: KbqCodeBlockLocaleConfiguration = inject(KBQ_CODE_BLOCK_LOCALE_CONFIGURATION); + // A getter over the signal rather than `localeConfiguration()`: every read site — template and the + // imperative tooltip updates alike — keeps its current shape, while the template read now registers + // the locale dependency on this view and re-renders on `setLocale()` without a `markForCheck`. + private readonly _localeConfiguration = kbqInjectLocaleConfiguration( + 'codeBlock', + KBQ_CODE_BLOCK_LOCALE_CONFIGURATION + ); /** * Code content tab index. @@ -348,7 +358,6 @@ export class KbqCodeBlock implements AfterViewInit { private readonly elementRef = inject>(ElementRef); private readonly injector = inject(Injector); private readonly changeDetectorRef = inject(ChangeDetectorRef); - private readonly localeService = inject(KBQ_LOCALE_SERVICE, { optional: true }); private readonly destroyRef = inject(DestroyRef); private readonly platform = inject(Platform); private readonly focusMonitor = inject(FocusMonitor); @@ -374,7 +383,6 @@ export class KbqCodeBlock implements AfterViewInit { constructor() { this.trackHoverState(); - this.localeService?.changes.pipe(takeUntilDestroyed()).subscribe(this.updateLocaleParams); } ngAfterViewInit(): void { @@ -532,12 +540,6 @@ export class KbqCodeBlock implements AfterViewInit { } } - private updateLocaleParams = (): void => { - this._localeConfiguration = this.localeService?.getParams('codeBlock'); - - this.changeDetectorRef.markForCheck(); - }; - /** * Copies the file code to the clipboard. * diff --git a/packages/components/core/formatters/filesize/formatter.spec.ts b/packages/components/core/formatters/filesize/formatter.spec.ts index 953f6590b6..6338f0b406 100644 --- a/packages/components/core/formatters/filesize/formatter.spec.ts +++ b/packages/components/core/formatters/filesize/formatter.spec.ts @@ -207,6 +207,13 @@ describe('Filesize formatter', () => { ); expect(localeService.id).not.toEqual(selectedLocale); }); + + it('should fall back to the active config for a locale that was never registered', () => { + // The 4th parameter takes any string, and an id nobody registered has no entry to read at all. + expect(localeService.locales['en-GB']).toBeUndefined(); + + expect(() => pipe.transform(1500, 1, KbqMeasurementSystem.SI, 'en-GB')).not.toThrow(); + }); }); describe('with localeService is not provided', () => { diff --git a/packages/components/core/formatters/filesize/formatter.ts b/packages/components/core/formatters/filesize/formatter.ts index 24bb2ae390..4bbdb459fa 100644 --- a/packages/components/core/formatters/filesize/formatter.ts +++ b/packages/components/core/formatters/filesize/formatter.ts @@ -40,9 +40,10 @@ export class KbqDataSizePipe implements PipeTransform { unitSystemName: KbqMeasurementSystemType = this.config.defaultUnitSystem, locale: string = this.localeService?.id || KBQ_DEFAULT_LOCALE_ID ): string { - const resolvedUnitSystems: Record = this.localeService - ? this.localeService.locales[locale].sizeUnits.unitSystems - : this.config.unitSystems; + // A locale id that was never registered has no entry at all — guard the lookup, not just the + // service, the way the number pipes already do. + const resolvedUnitSystems: Record = + this.localeService?.locales[locale]?.sizeUnits.unitSystems ?? this.config.unitSystems; const { value, unit } = getFormattedSizeParts(source, resolvedUnitSystems[unitSystemName]); @@ -52,6 +53,7 @@ export class KbqDataSizePipe implements PipeTransform { } private updateLocaleParams = () => { - this.config = this.externalConfig || this.localeService?.getParams('sizeUnits'); + this.config = + this.externalConfig ?? this.localeService?.getParams('sizeUnits') ?? KBQ_SIZE_UNITS_DEFAULT_CONFIG; }; } diff --git a/packages/components/core/formatters/number/formatter.ts b/packages/components/core/formatters/number/formatter.ts index 7f08f1f7c3..8a7bf4b646 100644 --- a/packages/components/core/formatters/number/formatter.ts +++ b/packages/components/core/formatters/number/formatter.ts @@ -1,12 +1,14 @@ import { coerceNumberProperty } from '@angular/cdk/coercion'; import { Injectable, InjectionToken, Pipe, PipeTransform, inject } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { KBQ_DEFAULT_LOCALE_ID, KBQ_LOCALE_ID, KBQ_LOCALE_SERVICE, KbqLocaleService, KbqNumberFormatOptions, - KbqNumberRoundingLocaleConfig + KbqNumberRoundingLocaleConfiguration, + ruRUFormattersData } from '../../locales'; export const KBQ_NUMBER_FORMATTER_OPTIONS = new InjectionToken('KbqNumberFormatterOptions'); @@ -68,7 +70,7 @@ const minFractionGroupPosition = 3; const maxFractionGroupPosition = 5; const useGroupingPosition = 7; -type RoundDecimalOptions = KbqNumberRoundingLocaleConfig & { +type RoundDecimalOptions = KbqNumberRoundingLocaleConfiguration & { /** Label for the ten-thousand unit. */ tenThousand?: string; /** Label for the one-hundred-millions unit. */ @@ -85,7 +87,7 @@ const ROUNDING_UNITS = { trillion: 1e12 }; -/** Rounding units that carry a localized label in `KbqNumberRoundingLocaleConfig`. */ +/** Rounding units that carry a localized label in `KbqNumberRoundingLocaleConfiguration`. */ type RoundingUnit = keyof RoundDecimalOptions & keyof typeof ROUNDING_UNITS; const intervalsConfig = { @@ -155,7 +157,7 @@ export class KbqDecimalPipe implements KbqNumericPipe, PipeTransform { constructor() { this.options = this.options || KBQ_NUMBER_FORMATTER_DEFAULT_OPTIONS; - this.localeService?.changes.subscribe((newId: string) => (this.id = newId)); + this.localeService?.changes.pipe(takeUntilDestroyed()).subscribe((newId: string) => (this.id = newId)); } /** @@ -228,7 +230,7 @@ export class KbqTableNumberPipe implements KbqNumericPipe, PipeTransform { constructor() { this.options = this.options || KBQ_NUMBER_FORMATTER_DEFAULT_OPTIONS; - this.localeService?.changes.subscribe((newId: string) => (this.id = newId)); + this.localeService?.changes.pipe(takeUntilDestroyed()).subscribe((newId: string) => (this.id = newId)); } /** @@ -292,7 +294,7 @@ export class KbqRoundDecimalPipe implements PipeTransform { roundingOptions: RoundDecimalOptions; constructor() { - this.localeService?.changes.subscribe((newId: string) => (this.id = newId)); + this.localeService?.changes.pipe(takeUntilDestroyed()).subscribe((newId: string) => (this.id = newId)); } // @TODO: update returned type to string | null. Breaking change @@ -303,7 +305,11 @@ export class KbqRoundDecimalPipe implements PipeTransform { const currentLocale: string = locale || this.id || KBQ_DEFAULT_LOCALE_ID; - this.roundingOptions = this.localeService?.locales[currentLocale].formatters.number.rounding; + // A locale id that was never registered has no entry at all — guard the lookup, not just the + // service, the way the decimal pipes above already do. + this.roundingOptions = + this.localeService?.locales[currentLocale]?.formatters.number.rounding ?? + ruRUFormattersData.formatters.number.rounding; try { const num = strToNumber(value); diff --git a/packages/components/core/locales/a11y.spec.ts b/packages/components/core/locales/a11y.spec.ts index 53db1a4efb..1d3e2358b0 100644 --- a/packages/components/core/locales/a11y.spec.ts +++ b/packages/components/core/locales/a11y.spec.ts @@ -1,14 +1,15 @@ +import { Component, InjectionToken } from '@angular/core'; import { TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { BehaviorSubject } from 'rxjs'; import { kbqA11yLocaleConfigurationProvider, kbqInjectA11yLocaleConfiguration } from './a11y'; +import { kbqInjectLocaleConfiguration, kbqLocaleConfigurationOverrideProvider } from './configuration'; import { enUSLocaleData } from './en-US'; -import { esLALocaleData } from './es-LA'; import { KBQ_LOCALE_SERVICE, KbqLocaleService } from './locale-service'; -import { ptBRLocaleData } from './pt-BR'; import { ruRULocaleData } from './ru-RU'; -import { tkTMLocaleData } from './tk-TM'; -import { KbqA11yLocaleConfiguration } from './types'; +import { KbqSelectLocaleConfiguration } from './types'; -describe('kbqInjectKbqA11yLocaleConfiguration', () => { +describe('kbqInjectA11yLocaleConfiguration', () => { const inject = () => TestBed.runInInjectionContext(kbqInjectA11yLocaleConfiguration); it('should fall back to the default locale when no locale service is provided', () => { @@ -25,6 +26,83 @@ describe('kbqInjectKbqA11yLocaleConfiguration', () => { expect(inject()().close).toBe('Custom close'); }); + it('should apply the override on top of the active locale', () => { + TestBed.configureTestingModule({ + providers: [ + { provide: KBQ_LOCALE_SERVICE, useClass: KbqLocaleService }, + kbqA11yLocaleConfigurationProvider({ close: 'Dismiss' }) + ] + }); + + const configuration = inject(); + + expect(configuration().close).toBe('Dismiss'); + + TestBed.inject(KBQ_LOCALE_SERVICE).setLocale('en-US'); + + // The overridden name stays pinned; the rest of the section follows the locale. + expect(configuration().close).toBe('Dismiss'); + expect(configuration().save).toBe(enUSLocaleData.a11y.save); + }); + + it('should apply every section overridden in the same providers array', () => { + // The realistic case is two components' providers side by side, which is why the overrides token is + // `multi`: a single-value one would let the second provider drop the first. + const selectConfiguration = new InjectionToken('SelectLocaleConfiguration', { + factory: () => ruRULocaleData.select + }); + + TestBed.configureTestingModule({ + providers: [ + kbqA11yLocaleConfigurationProvider({ close: 'Dismiss' }), + kbqLocaleConfigurationOverrideProvider('select', { selectAll: 'Everything' }) + ] + }); + + const select = TestBed.runInInjectionContext(() => kbqInjectLocaleConfiguration('select', selectConfiguration)); + + expect(inject()().close).toBe('Dismiss'); + expect(select().selectAll).toBe('Everything'); + }); + + it('should apply sections overridden at different levels of the injector tree', () => { + // Scoping an override to a component is what the localization guide recommends, and every section + // shares one `multi` token — which Angular resolves from the nearest injector that has any entry + // for it, without merging the levels above. + const selectConfiguration = new InjectionToken('SelectLocaleConfiguration', { + factory: () => ruRULocaleData.select + }); + + @Component({ + selector: 'scoped-override', + template: '', + providers: [kbqLocaleConfigurationOverrideProvider('select', { selectAll: 'Everything' })] + }) + class ScopedOverride { + readonly a11y = kbqInjectA11yLocaleConfiguration(); + readonly select = kbqInjectLocaleConfiguration('select', selectConfiguration); + } + + @Component({ + selector: 'root-override', + imports: [ScopedOverride], + template: '', + providers: [kbqA11yLocaleConfigurationProvider({ close: 'Dismiss' })] + }) + class RootOverride {} + + TestBed.configureTestingModule({}); + + const fixture = TestBed.createComponent(RootOverride); + + fixture.detectChanges(); + + const scoped: ScopedOverride = fixture.debugElement.query(By.directive(ScopedOverride)).componentInstance; + + expect(scoped.select().selectAll).toBe('Everything'); + expect(scoped.a11y().close).toBe('Dismiss'); + }); + it('should follow the locale service', () => { TestBed.configureTestingModule({ providers: [{ provide: KBQ_LOCALE_SERVICE, useClass: KbqLocaleService }] @@ -39,35 +117,26 @@ describe('kbqInjectKbqA11yLocaleConfiguration', () => { expect(configuration().close).toBe(enUSLocaleData.a11y.close); }); - it('should fall back when the active locale data carries no a11y section', () => { + it('should get a complete section for locale data registered without one', () => { TestBed.configureTestingModule({ - providers: [ - { provide: KBQ_LOCALE_SERVICE, useClass: KbqLocaleService } - ] + providers: [{ provide: KBQ_LOCALE_SERVICE, useClass: KbqLocaleService }] }); const localeService = TestBed.inject(KBQ_LOCALE_SERVICE); - // Locale data registered by a consumer may predate the section entirely. + // Locale data registered by a consumer may predate the section entirely; the service completes it. localeService.addLocale('custom', { select: { hiddenItemsText: '+{{ number }}' } }); expect(inject()()).toBe(ruRULocaleData.a11y); }); -}); -describe('a11y locale data', () => { - // A missing accessible name leaves the button nameless in that locale only, which no component - // test would catch — the section is asserted complete for every shipped locale instead. - const locales: [string, KbqA11yLocaleConfiguration][] = [ - ['en-US', enUSLocaleData.a11y], - ['es-LA', esLALocaleData.a11y], - ['pt-BR', ptBRLocaleData.a11y], - ['ru-RU', ruRULocaleData.a11y], - ['tk-TM', tkTMLocaleData.a11y] - ]; - - it.each(locales)('should provide every accessible name for %s', (_, data) => { - expect(Object.keys(data).sort()).toEqual(Object.keys(ruRULocaleData.a11y).sort()); - Object.values(data).forEach((name) => expect(name.trim()).not.toBe('')); + it('should fall back when the locale service hands back no section at all', () => { + // `KbqLocaleService` itself always completes a section, but applications routinely provide a stand-in + // under `KBQ_LOCALE_SERVICE` in their own tests — that one is free to return nothing. + const stub = { changes: new BehaviorSubject('custom'), getParams: () => undefined }; + + TestBed.configureTestingModule({ providers: [{ provide: KBQ_LOCALE_SERVICE, useValue: stub }] }); + + expect(inject()()).toBe(ruRULocaleData.a11y); }); }); diff --git a/packages/components/core/locales/a11y.ts b/packages/components/core/locales/a11y.ts index 26459743b0..834e71c1c1 100644 --- a/packages/components/core/locales/a11y.ts +++ b/packages/components/core/locales/a11y.ts @@ -1,7 +1,6 @@ -import { inject, InjectionToken, Provider, Signal } from '@angular/core'; -import { toSignal } from '@angular/core/rxjs-interop'; -import { map, of } from 'rxjs'; -import { KBQ_LOCALE_SERVICE } from './locale-service'; +import { InjectionToken, Provider, Signal } from '@angular/core'; +import { KbqDeepPartial } from '../utils'; +import { kbqInjectLocaleConfiguration, kbqLocaleConfigurationOverrideProvider } from './configuration'; import { ruRULocaleData } from './ru-RU'; import { KbqA11yLocaleConfiguration } from './types'; @@ -14,13 +13,13 @@ export const KBQ_A11Y_LOCALE_CONFIGURATION = new InjectionToken ({ - provide: KBQ_A11Y_LOCALE_CONFIGURATION, - useValue: configuration -}); +export const kbqA11yLocaleConfigurationProvider = ( + configuration: KbqDeepPartial +): Provider => kbqLocaleConfigurationOverrideProvider('a11y', configuration); /** * Injection function that creates a reactive locale configuration signal with the accessible names @@ -29,16 +28,5 @@ export const kbqA11yLocaleConfigurationProvider = (configuration: KbqA11yLocaleC * @docs-private */ export function kbqInjectA11yLocaleConfiguration(): Signal { - const localeService = inject(KBQ_LOCALE_SERVICE, { optional: true }); - const initialValue = inject(KBQ_A11Y_LOCALE_CONFIGURATION); - const configuration = localeService - ? localeService.changes.pipe( - // Custom locale data registered through `KBQ_LOCALE_DATA`/`addLocale` may predate this - // section; falling back keeps the close buttons of modal, popover and sidepanel - // rendering instead of throwing on an undefined configuration. - map(() => localeService.getParams('a11y') ?? initialValue) - ) - : of(initialValue); - - return toSignal(configuration, { initialValue }); + return kbqInjectLocaleConfiguration('a11y', KBQ_A11Y_LOCALE_CONFIGURATION); } diff --git a/packages/components/core/locales/configuration.ts b/packages/components/core/locales/configuration.ts new file mode 100644 index 0000000000..aa8ef79ca7 --- /dev/null +++ b/packages/components/core/locales/configuration.ts @@ -0,0 +1,89 @@ +import { inject, InjectionToken, Provider, Signal } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { map, of } from 'rxjs'; +import { kbqDeepMerge, KbqDeepPartial } from '../utils'; +import { KBQ_LOCALE_SERVICE } from './locale-service'; +import { KbqLocaleData, KbqLocaleSection, KbqPartialLocaleData } from './types'; + +/** + * Consumer overrides of individual locale sections, contributed by every + * `kbqLocaleConfigurationProvider`. + * + * `multi` because overriding several sections in one `providers` array is the common case: a single-value + * token would let the last provider silently drop every other one. Each entry is a batch rather than a + * single override, so that a provider can also re-contribute what it inherited — see + * {@link kbqLocaleConfigurationOverrideProvider}. + * + * @docs-private + */ +// The annotation is load-bearing: `KbqPartialLocaleData` resolves to a conditional type, which TypeScript +// evaluates when emitting an unannotated declaration — inlining 250 lines of expanded shape into the +// public API report. +export const KBQ_LOCALE_CONFIGURATION_OVERRIDES: InjectionToken = new InjectionToken< + KbqPartialLocaleData[][] +>('KBQ_LOCALE_CONFIGURATION_OVERRIDES'); + +/** + * Registers a partial override of one locale section. + * + * Backs every `kbqLocaleConfigurationProvider`. The override is applied on top of the active + * locale rather than replacing it, so the keys it does not mention keep following `setLocale()`. + * + * @param section Section of the locale data to override. + * @param configuration Strings to override; every key is optional at every depth. + */ +export const kbqLocaleConfigurationOverrideProvider = ( + section: K, + configuration: KbqDeepPartial +): Provider => [ + { + provide: KBQ_LOCALE_CONFIGURATION_OVERRIDES, + // Angular resolves a `multi` token from the nearest injector holding any entry for it and never + // merges the levels above. Without re-contributing them here, scoping one section to a component — + // which is exactly what the localization guide recommends — would hide every section an ancestor + // overrode from that whole subtree. + useFactory: () => (inject(KBQ_LOCALE_CONFIGURATION_OVERRIDES, { skipSelf: true, optional: true }) ?? []).flat(), + multi: true + }, + { + provide: KBQ_LOCALE_CONFIGURATION_OVERRIDES, + // A computed key widens to an index signature, and `section` is the type parameter itself — this is the + // single place that has to assert the shape, in exchange for a precisely typed call site. + useValue: [{ [section]: configuration } as KbqPartialLocaleData], + multi: true + } +]; + +/** + * Reactive localized strings for one section of the active locale. + * + * Follows `KBQ_LOCALE_SERVICE` when the application provides one, and otherwise resolves `token`, whose + * factory supplies the default strings. Overrides registered through + * {@link kbqLocaleConfigurationOverrideProvider} are merged on top of whichever of the two applies. Being a + * signal is what makes a runtime `setLocale()` reach `OnPush` children that render these strings: they + * register the read on their own view, which a subscription in the parent could never do for them. + * + * @param section Section of the locale data to read. + * @param token Configuration token, whose factory supplies the default strings. + */ +export function kbqInjectLocaleConfiguration( + section: K, + token: InjectionToken +): Signal { + const localeService = inject(KBQ_LOCALE_SERVICE, { optional: true }); + // Every provider at this level re-contributes the inherited batch, so an ancestor's override arrives + // once per provider. `Set` keeps the first occurrence of each, which is the one that preserves + // ancestor-before-descendant precedence. + const overrides = [...new Set((inject(KBQ_LOCALE_CONFIGURATION_OVERRIDES, { optional: true }) || []).flat())]; + const defaultValue = inject(token); + // `kbqDeepMerge` returns its base untouched when a patch adds nothing, so an unoverridden section stays + // referentially identical to the data the locale service holds. + const withOverrides = (configuration: KbqLocaleData[K]): KbqLocaleData[K] => + overrides.reduce((result, override) => kbqDeepMerge(result, override[section]), configuration); + const initialValue = withOverrides(defaultValue); + const configuration = localeService + ? localeService.changes.pipe(map(() => withOverrides(localeService.getParams(section) ?? defaultValue))) + : of(initialValue); + + return toSignal(configuration, { initialValue }); +} diff --git a/packages/components/core/locales/en-US.ts b/packages/components/core/locales/en-US.ts index 0d04ee509e..1544677cc9 100644 --- a/packages/components/core/locales/en-US.ts +++ b/packages/components/core/locales/en-US.ts @@ -1,13 +1,4 @@ -import { - KbqA11yLocaleConfiguration, - KbqActionsPanelLocaleConfiguration, - KbqAppSwitcherConfiguration, - KbqClampedTextLocaleConfig, - KbqCodeBlockLocaleConfiguration, - KbqFileUploadLocaleConfig, - KbqSelectLocaleConfiguration, - KbqTimeRangeLocaleConfig -} from './types'; +import { KbqLocaleStringsData } from './types'; export const enUSLocaleData = { a11y: { @@ -22,8 +13,8 @@ export const enUSLocaleData = { clear: 'Clear', showPassword: 'Show password', hidePassword: 'Hide password' - } satisfies KbqA11yLocaleConfiguration, - select: { hiddenItemsText: '+{{ number }}', selectAll: 'Select all' } satisfies KbqSelectLocaleConfiguration, + }, + select: { hiddenItemsText: '+{{ number }}', selectAll: 'Select all' }, datepicker: { placeholder: 'yyyy-mm-dd', dateInput: 'yyyy-MM-dd' @@ -54,7 +45,7 @@ export const enUSLocaleData = { browseLinkFolderMixed: 'folder', title: 'Drag here' } - } satisfies KbqFileUploadLocaleConfig, + }, codeBlock: { softWrapOnTooltip: 'Enable word wrap', softWrapOffTooltip: 'Disable word wrap', @@ -64,13 +55,13 @@ export const enUSLocaleData = { viewAllText: 'Show all', viewLessText: 'Show less', openExternalSystemTooltip: 'Open in the external system' - } satisfies KbqCodeBlockLocaleConfiguration, + }, timezone: { searchPlaceholder: 'City or time zone' }, actionsPanel: { closeTooltip: 'Deselect' - } satisfies KbqActionsPanelLocaleConfiguration, + }, filterBar: { reset: { buttonName: 'Reset' @@ -127,7 +118,7 @@ export const enUSLocaleData = { closeText: 'Collapse', showMoreText: 'Show {exceededItemCount} more', moreText: 'more' - } satisfies KbqClampedTextLocaleConfig, + }, navbarIc: { toggle: { pinButton: 'Leave expanded', @@ -149,7 +140,7 @@ export const enUSLocaleData = { searchEmptyResult: 'Nothing found', sitesHeader: 'Other sites', clearSearch: 'Clear search' - } satisfies KbqAppSwitcherConfiguration, + }, timeRange: { title: { for: 'for', @@ -238,7 +229,7 @@ export const enUSLocaleData = { MONTHS_FRACTION: `{months} months` } } - } satisfies KbqTimeRangeLocaleConfig, + }, notificationCenter: { notifications: 'Notifications', remove: 'Remove', @@ -249,4 +240,4 @@ export const enUSLocaleData = { repeat: 'Repeat', loadingMore: 'Loading more notifications' } -}; +} satisfies KbqLocaleStringsData; diff --git a/packages/components/core/locales/es-LA.ts b/packages/components/core/locales/es-LA.ts index 876878ff45..0d80079406 100644 --- a/packages/components/core/locales/es-LA.ts +++ b/packages/components/core/locales/es-LA.ts @@ -1,13 +1,4 @@ -import { - KbqA11yLocaleConfiguration, - KbqActionsPanelLocaleConfiguration, - KbqAppSwitcherConfiguration, - KbqClampedTextLocaleConfig, - KbqCodeBlockLocaleConfiguration, - KbqFileUploadLocaleConfig, - KbqSelectLocaleConfiguration, - KbqTimeRangeLocaleConfig -} from './types'; +import { KbqLocaleStringsData } from './types'; export const esLALocaleData = { a11y: { @@ -22,11 +13,11 @@ export const esLALocaleData = { clear: 'Borrar', showPassword: 'Mostrar la contraseña', hidePassword: 'Ocultar la contraseña' - } satisfies KbqA11yLocaleConfiguration, + }, select: { hiddenItemsText: '+{{ number }}', selectAll: 'Seleccionar todo' - } satisfies KbqSelectLocaleConfiguration, + }, datepicker: { placeholder: 'dd/mm/aaaa' }, @@ -56,7 +47,7 @@ export const esLALocaleData = { browseLinkFolderMixed: 'carpeta', title: 'Cargue los archivos' } - } satisfies KbqFileUploadLocaleConfig, + }, codeBlock: { softWrapOnTooltip: 'Activar el ajuste de texto', softWrapOffTooltip: 'Desactivar el ajuste de texto', @@ -66,13 +57,13 @@ export const esLALocaleData = { viewAllText: 'Mostrar todo', viewLessText: 'Mostrar menos', openExternalSystemTooltip: 'Abrir en el sistema externo' - } satisfies KbqCodeBlockLocaleConfiguration, + }, timezone: { searchPlaceholder: 'Ciudad o zona horaria' }, actionsPanel: { closeTooltip: 'Desmarque' - } satisfies KbqActionsPanelLocaleConfiguration, + }, filterBar: { reset: { buttonName: 'Restablecer' @@ -129,7 +120,7 @@ export const esLALocaleData = { closeText: 'Contraer', showMoreText: 'Mostrar {exceededItemCount} más', moreText: 'más' - } satisfies KbqClampedTextLocaleConfig, + }, navbarIc: { toggle: { pinButton: 'Expandir el menú', @@ -151,7 +142,7 @@ export const esLALocaleData = { searchEmptyResult: 'No se encontró nada', sitesHeader: 'Otros sitios', clearSearch: 'Borrar la búsqueda' - } satisfies KbqAppSwitcherConfiguration, + }, timeRange: { title: { for: 'para', @@ -240,7 +231,7 @@ export const esLALocaleData = { MONTHS_FRACTION: `{months} meses` } } - } satisfies KbqTimeRangeLocaleConfig, + }, notificationCenter: { notifications: 'Notificaciones', remove: 'Eliminar', @@ -251,4 +242,4 @@ export const esLALocaleData = { repeat: 'Repetir', loadingMore: 'Cargando más notificaciones' } -}; +} satisfies KbqLocaleStringsData; diff --git a/packages/components/core/locales/formatters.ts b/packages/components/core/locales/formatters.ts index af914eb10e..3343ce54e9 100644 --- a/packages/components/core/locales/formatters.ts +++ b/packages/components/core/locales/formatters.ts @@ -1,5 +1,4 @@ -import { KbqSizeUnitsConfig } from '../formatters'; -import { KbqNumberFormatOptions, KbqNumberInputLocaleConfig, KbqNumberRoundingLocaleConfig } from './types'; +import { KbqLocaleFormattersData } from './types'; export const enUSFormattersData = { formatters: { @@ -11,14 +10,14 @@ export const enUSFormattersData = { million: 'M', billion: 'B', trillion: 'T' - } satisfies KbqNumberRoundingLocaleConfig + } } }, input: { number: { groupSeparator: [','], fractionSeparator: '.' - } satisfies KbqNumberInputLocaleConfig + } }, sizeUnits: { defaultUnitSystem: 'SI', @@ -35,8 +34,8 @@ export const enUSFormattersData = { power: 10 } } - } satisfies KbqSizeUnitsConfig -}; + } +} satisfies KbqLocaleFormattersData; export const esLAFormattersData = { formatters: { @@ -48,10 +47,10 @@ export const esLAFormattersData = { million: 'M', billion: 'MRD', trillion: 'B' - } satisfies KbqNumberRoundingLocaleConfig, + }, decimal: { viewGroupSeparator: '\u2009' - } satisfies KbqNumberFormatOptions + } } }, input: { @@ -60,7 +59,7 @@ export const esLAFormattersData = { groupSeparator: [' ', ' ', '\u2009'], fractionSeparator: ',', viewGroupSeparator: '\u2009' - } satisfies KbqNumberInputLocaleConfig + } }, sizeUnits: { defaultUnitSystem: 'SI', @@ -78,7 +77,7 @@ export const esLAFormattersData = { } } } -}; +} satisfies KbqLocaleFormattersData; export const ptBRFormattersData = { formatters: { @@ -90,14 +89,14 @@ export const ptBRFormattersData = { million: 'mi', billion: 'bi', trillion: 'tri' - } satisfies KbqNumberRoundingLocaleConfig + } } }, input: { number: { groupSeparator: ['.'], fractionSeparator: ',' - } satisfies KbqNumberInputLocaleConfig + } }, sizeUnits: { defaultUnitSystem: 'SI', @@ -115,7 +114,7 @@ export const ptBRFormattersData = { } } } -}; +} satisfies KbqLocaleFormattersData; export const ruRUFormattersData = { formatters: { @@ -128,10 +127,10 @@ export const ruRUFormattersData = { // Latin `B` (U+0042) according to UX Guidelines. billion: 'B', trillion: 'Т' - } satisfies KbqNumberRoundingLocaleConfig, + }, decimal: { viewGroupSeparator: '\u2009' - } satisfies KbqNumberFormatOptions + } } }, input: { @@ -141,7 +140,7 @@ export const ruRUFormattersData = { fractionSeparator: ',', startFormattingFrom: 4, viewGroupSeparator: '\u2009' - } satisfies KbqNumberInputLocaleConfig + } }, sizeUnits: { defaultUnitSystem: 'SI', @@ -159,7 +158,7 @@ export const ruRUFormattersData = { } } } -}; +} satisfies KbqLocaleFormattersData; export const tkTMFormattersData = { formatters: { @@ -171,7 +170,7 @@ export const tkTMFormattersData = { million: 'Mn', billion: 'Mr', trillion: 'Tn' - } satisfies KbqNumberRoundingLocaleConfig + } } }, input: { @@ -179,7 +178,7 @@ export const tkTMFormattersData = { groupSeparator: [' ', ' ', '\u2009'], fractionSeparator: ',', viewGroupSeparator: '\u2009' - } satisfies KbqNumberInputLocaleConfig + } }, sizeUnits: { defaultUnitSystem: 'SI', @@ -197,4 +196,4 @@ export const tkTMFormattersData = { } } } -}; +} satisfies KbqLocaleFormattersData; diff --git a/packages/components/core/locales/index.ts b/packages/components/core/locales/index.ts index fa9b6d7766..6823609c29 100644 --- a/packages/components/core/locales/index.ts +++ b/packages/components/core/locales/index.ts @@ -1,8 +1,10 @@ export * from './a11y'; +export * from './configuration'; export * from './en-US'; export * from './es-LA'; export * from './pt-BR'; export * from './ru-RU'; +export * from './select'; export * from './tk-TM'; export * from './formatters'; diff --git a/packages/components/core/locales/locale-service.spec.ts b/packages/components/core/locales/locale-service.spec.ts index ffedb6c6a8..1abf003875 100644 --- a/packages/components/core/locales/locale-service.spec.ts +++ b/packages/components/core/locales/locale-service.spec.ts @@ -1,20 +1,328 @@ import { TestBed } from '@angular/core/testing'; -import { KbqLocaleService } from './locale-service'; +import { enUSLocaleData } from './en-US'; +import { esLALocaleData } from './es-LA'; +import { enUSFormattersData, ruRUFormattersData } from './formatters'; +import { + checkAndNormalizeLocalizedNumber, + KBQ_DEFAULT_LOCALE_ID, + KBQ_LOCALE_DATA, + KBQ_LOCALE_ID, + KbqLocaleService, + kbqLocaleServiceLangAttrNameProvider, + normalizeNumber, + numberByParts +} from './locale-service'; +import { ptBRLocaleData } from './pt-BR'; +import { ruRULocaleData } from './ru-RU'; +import { tkTMLocaleData } from './tk-TM'; + +const createService = (providers: unknown[] = []): KbqLocaleService => { + TestBed.configureTestingModule({ providers: [KbqLocaleService, ...(providers as [])] }); + + return TestBed.inject(KbqLocaleService); +}; describe('KbqLocaleService', () => { - let service: KbqLocaleService; + describe('active locale', () => { + it('should fall back to the default locale when KBQ_LOCALE_ID is not provided', () => { + const service = createService(); + + expect(service.localeId()).toBe(KBQ_DEFAULT_LOCALE_ID); + expect(service.data()).toBe(service.locales[KBQ_DEFAULT_LOCALE_ID]); + }); + + it('should use the locale provided through KBQ_LOCALE_ID', () => { + const service = createService([{ provide: KBQ_LOCALE_ID, useValue: 'en-US' }]); + + expect(service.localeId()).toBe('en-US'); + expect(service.getParams('a11y').close).toBe(enUSLocaleData.a11y.close); + }); + + it('should change the lang attribute of the html element', () => { + const locale = 'ru-RU'; + + createService().setLocale(locale); + + expect(document.documentElement.lang).toBe(locale); + }); + + it('should use the attribute name configured through KBQ_LOCALE_SERVICE_LANG_ATTR_NAME', () => { + const service = createService([kbqLocaleServiceLangAttrNameProvider('examples-lang')]); + + service.setLocale('en-US'); + + expect(document.documentElement.getAttribute('examples-lang')).toBe('en-US'); + }); + + it('should move the signals, the deprecated fields and the changes stream together', () => { + const service = createService(); + const emitted: string[] = []; + + service.changes.subscribe((id) => emitted.push(id)); + service.setLocale('en-US'); + + expect(service.localeId()).toBe('en-US'); + expect(service.id).toBe('en-US'); + expect(service.current).toBe(service.data()); + expect(service.data()).toBe(service.locales['en-US']); + expect(emitted).toEqual([KBQ_DEFAULT_LOCALE_ID, 'en-US']); + }); + + it('should expose the registered locales for a locale picker', () => { + const service = createService(); + + expect(service.items()).toBe(service.locales.items); + expect(service.items().map(({ id }) => id)).toContain(KBQ_DEFAULT_LOCALE_ID); + }); + }); + + describe('getParams', () => { + it('should return the section of the active locale', () => { + const service = createService(); + + expect(service.getParams('select')).toBe(ruRULocaleData.select); + + service.setLocale('en-US'); - beforeEach(() => { - TestBed.configureTestingModule({ - providers: [KbqLocaleService] + expect(service.getParams('select')).toBe(enUSLocaleData.select); + }); + + it('should fall back to the default locale for a section the active data does not carry', () => { + const service = createService(); + + // Only reachable by writing `current` directly, which the deprecated setter still allows. + service.current = {} as never; + + expect(service.getParams('a11y')).toBe(ruRULocaleData.a11y); + }); + }); + + describe('addLocale', () => { + it('should register and activate the locale', () => { + const service = createService(); + + service.addLocale('custom', { select: { selectAll: 'Everything' } }); + + expect(service.localeId()).toBe('custom'); + expect(service.data()).toBe(service.locales.custom); + expect(service.getParams('select').selectAll).toBe('Everything'); + }); + + it('should complete a partial locale from the default locale', () => { + const service = createService(); + + service.addLocale('custom', { select: { selectAll: 'Everything' } }); + + // The overridden section keeps the keys it did not mention... + expect(service.getParams('select').hiddenItemsText).toBe(ruRULocaleData.select.hiddenItemsText); + // ...and every untouched section stays referentially identical to the shipped data, which is + // what lets consumers keep comparing sections by reference. + expect(service.getParams('a11y')).toBe(ruRULocaleData.a11y); + expect(service.getParams('codeBlock')).toBe(ruRULocaleData.codeBlock); + }); + + it('should complete a partial override of a shipped locale from that same locale', () => { + const service = createService(); + + service.addLocale('en-US', { select: { selectAll: 'Everything' } }); + + expect(service.getParams('select').hiddenItemsText).toBe(enUSLocaleData.select.hiddenItemsText); + expect(service.getParams('a11y')).toBe(enUSLocaleData.a11y); + }); + + it('should leave a section rendered by no component alone', () => { + const service = createService(); + + service.addLocale('custom', {}); + + expect(service.getParams('navbar')).toBe(ruRULocaleData.navbar); }); - service = TestBed.inject(KbqLocaleService); }); - it('should change the lang attribute of the html element', () => { - const locale = 'ru-RU'; + describe('shipped locale data', () => { + // Completing a locale from the default one must never reach the shipped locales themselves: + // `formatters.number.decimal` exists for ru-RU only, and leaking it into en-US would silently + // change the group separator of every en-US number. + it('should not let one shipped locale inherit an optional key from another', () => { + const service = createService(); + + expect(ruRUFormattersData.formatters.number).toHaveProperty('decimal'); + expect(enUSFormattersData.formatters.number).not.toHaveProperty('decimal'); + expect(service.locales['en-US'].formatters.number.decimal).toBeUndefined(); + }); + + it('should keep every shipped locale referentially identical to its source data', () => { + const service = createService(); + + expect(service.locales['en-US'].a11y).toBe(enUSLocaleData.a11y); + expect(service.locales['ru-RU'].sizeUnits).toBe(ruRUFormattersData.sizeUnits); + }); - service.setLocale(locale); - expect(document.documentElement.lang).toBe(locale); + it('should accept partial data through KBQ_LOCALE_DATA', () => { + const service = createService([ + { + provide: KBQ_LOCALE_DATA, + useValue: { 'ru-RU': { select: { selectAll: 'Everything' } } } + } + ]); + + expect(service.getParams('select').selectAll).toBe('Everything'); + expect(service.getParams('a11y')).toBe(ruRULocaleData.a11y); + expect(service.items()).toBe(service.locales.items); + }); + }); + + describe('locale registry', () => { + // Any locale a picker can offer must have data behind it: `data()` promises a complete locale, and + // consumers index `locales[id]` directly — `KbqDataSizePipe` reads `locales[locale].sizeUnits`. + it('should register every locale the picker offers when KBQ_LOCALE_DATA patches a single one', () => { + const service = createService([ + { + provide: KBQ_LOCALE_DATA, + useValue: { 'ru-RU': { select: { selectAll: 'Everything' } } } + } + ]); + + expect(service.items().map(({ id }) => id)).toContain('en-US'); + expect(service.items().filter(({ id }) => !service.locales[id])).toEqual([]); + + service.setLocale('en-US'); + + expect(service.data()).toBe(service.locales['en-US']); + expect(service.getParams('a11y')).toBe(enUSLocaleData.a11y); + expect(service.getParams('select').selectAll).toBe(enUSLocaleData.select.selectAll); + }); + + it('should register a locale offered only through a custom items list', () => { + const service = createService([ + { + provide: KBQ_LOCALE_DATA, + useValue: { items: [{ id: 'de-DE', name: 'Deutsch' }] } + } + ]); + + expect(service.items().map(({ id }) => id)).toEqual(['de-DE']); + expect(service.locales['de-DE']).toBeDefined(); + + service.setLocale('de-DE'); + + expect(service.data().a11y).toBe(ruRULocaleData.a11y); + expect(service.data().sizeUnits).toBe(ruRUFormattersData.sizeUnits); + }); + + it('should register an unknown id provided through KBQ_LOCALE_ID', () => { + // `KBQ_LOCALE_ID` takes any string, and the constructor activates it without going through + // `setLocale` — the id has to be registered there too, or `data()` starts out undefined. + const service = createService([{ provide: KBQ_LOCALE_ID, useValue: 'de-DE' }]); + + expect(service.data()).toBeDefined(); + expect(service.data()).toBe(service.locales['de-DE']); + expect(service.data().a11y).toBe(ruRULocaleData.a11y); + }); + + it('should register an unknown id passed to setLocale', () => { + const service = createService(); + + service.setLocale('de-DE'); + + expect(service.data()).toBeDefined(); + expect(service.locales['de-DE']).toBe(service.data()); + expect(service.data().a11y).toBe(ruRULocaleData.a11y); + expect(service.data().sizeUnits).toBe(ruRUFormattersData.sizeUnits); + }); + }); +}); + +describe('locale data completeness', () => { + // `satisfies KbqLocaleStringsData` already forces every locale to carry every required key. What it + // cannot catch is an *optional* key that one locale declares and another forgets — which is exactly how + // `datepicker.dateInput` came to exist in three locales and not the other two. + const collectEntries = (value: unknown, prefix = ''): [string, unknown][] => + value && typeof value === 'object' && !Array.isArray(value) + ? Object.entries(value).flatMap(([key, nested]) => { + const path = prefix ? `${prefix}.${key}` : key; + + return [[path, nested] as [string, unknown], ...collectEntries(nested, path)]; + }) + : []; + + /** + * `datepicker.dateInput` is declared by three locales and not the other two. It is dead data — the + * datepicker resolves its input format from `KBQ_DATE_FORMATS` and the date adapter — so the drift is + * harmless and the key is typed optional. Drop this entry together with the key itself. + */ + const knownDrift = ['datepicker.dateInput']; + + // `SEPARATOR`/`LAST_PART_SEPARATOR` join the parts of a rendered duration; blank and whitespace-only + // values are meaningful there, unlike in any label or accessible name. + const isSeparator = (path: string) => /\.(LAST_PART_)?SEPARATOR$/.test(path); + + const keyPathsOf = (data: object): string[] => + collectEntries(data) + .map(([path]) => path) + .filter((path) => !knownDrift.includes(path)) + .sort(); + + const locales: [string, object][] = [ + ['en-US', enUSLocaleData], + ['es-LA', esLALocaleData], + ['pt-BR', ptBRLocaleData], + ['tk-TM', tkTMLocaleData], + ['ru-RU', ruRULocaleData] + ]; + + it.each(locales)('should declare exactly the key paths of the default locale in %s', (_, data) => { + expect(keyPathsOf(data)).toEqual(keyPathsOf(ruRULocaleData)); + }); + + it.each(locales)('should leave no label blank in %s', (_, data) => { + const blank = collectEntries(data) + .filter(([path, value]) => typeof value === 'string' && value.trim() === '' && !isSeparator(path)) + .map(([path]) => path); + + expect(blank).toEqual([]); + }); +}); + +describe('number helpers', () => { + const ruConfig = ruRUFormattersData.input.number; + const enConfig = enUSFormattersData.input.number; + + describe('numberByParts', () => { + it('should split an integer', () => { + expect(numberByParts('1 234', ruConfig)).toEqual({ integer: '1234', fraction: '' }); + }); + + it('should split a fraction', () => { + expect(numberByParts('1234,56', ruConfig)).toEqual({ integer: '1234', fraction: '56' }); + }); + + it('should keep the sign of a negative number', () => { + expect(numberByParts('-1234,56', ruConfig).integer).toBe('-1234'); + }); + }); + + describe('normalizeNumber', () => { + it('should strip group separators and normalize the fraction separator', () => { + expect(normalizeNumber('1 234,56', ruConfig)).toBe('1234.56'); + expect(normalizeNumber('1,234.56', enConfig)).toBe('1234.56'); + }); + + it('should return an empty string for a missing value', () => { + expect(normalizeNumber(null, ruConfig)).toBe(''); + expect(normalizeNumber(undefined, ruConfig)).toBe(''); + }); + }); + + describe('checkAndNormalizeLocalizedNumber', () => { + it('should parse a number written in the given locale', () => { + expect(checkAndNormalizeLocalizedNumber('1 234,56', 'ru-RU')).toBe(1234.56); + expect(checkAndNormalizeLocalizedNumber('1,234.56', 'en-US')).toBe(1234.56); + }); + + it('should return null for a missing value', () => { + expect(checkAndNormalizeLocalizedNumber(null)).toBeNull(); + expect(checkAndNormalizeLocalizedNumber(undefined)).toBeNull(); + }); }); }); diff --git a/packages/components/core/locales/locale-service.ts b/packages/components/core/locales/locale-service.ts index e7a0d67e77..da27c1afc5 100644 --- a/packages/components/core/locales/locale-service.ts +++ b/packages/components/core/locales/locale-service.ts @@ -1,6 +1,7 @@ import { DOCUMENT } from '@angular/common'; -import { inject, Injectable, InjectionToken, Provider } from '@angular/core'; +import { computed, inject, Injectable, InjectionToken, Provider, Signal, signal } from '@angular/core'; import { BehaviorSubject } from 'rxjs'; +import { kbqDeepMerge } from '../utils'; import { enUSLocaleData } from './en-US'; import { esLALocaleData } from './es-LA'; import { @@ -13,9 +14,18 @@ import { import { ptBRLocaleData } from './pt-BR'; import { ruRULocaleData } from './ru-RU'; import { tkTMLocaleData } from './tk-TM'; -import { KbqNumberInputLocaleConfig } from './types'; - -export const KBQ_LOCALE_ID = new InjectionToken('KbqLocaleId'); +import { + KbqLocaleData, + KbqLocaleDataInput, + KbqLocaleDataMap, + KbqLocaleIdLike, + KbqLocaleItem, + KbqLocaleSection, + KbqNumberInputLocaleConfiguration, + KbqPartialLocaleData +} from './types'; + +export const KBQ_LOCALE_ID = new InjectionToken('KbqLocaleId'); export const KBQ_DEFAULT_LOCALE_ID = 'ru-RU'; @@ -35,11 +45,53 @@ export function KBQ_DEFAULT_LOCALE_DATA_FACTORY() { 'tk-TM': { ...tkTMLocaleData, ...tkTMFormattersData } }; } -export const KBQ_LOCALE_DATA = new InjectionToken('KBQ_LOCALE_DATA', { +export const KBQ_LOCALE_DATA = new InjectionToken('KBQ_LOCALE_DATA', { providedIn: 'root', factory: KBQ_DEFAULT_LOCALE_DATA_FACTORY }); +/** + * Assembles the locale registry. + * + * `items` is not a locale, yet it has always shared this object with them — which no index signature can + * express. {@link KbqLocaleDataMap} resolves that in favour of readers (`locales[id]` stays + * `KbqLocaleData`), and this is the single place that has to assert the shape when building it. + */ +const asLocaleDataMap = (locales: Record, items: KbqLocaleItem[]): KbqLocaleDataMap => + Object.assign(locales, { items }) as KbqLocaleDataMap; + +const { items: shippedLocaleItems, ...shippedLocales } = KBQ_DEFAULT_LOCALE_DATA_FACTORY(); + +/** + * The locales shipped with the library, used as the merge base for consumer-supplied locale data: a + * partial locale is completed from the shipped locale of the same id, or from {@link KBQ_DEFAULT_LOCALE_ID} + * when the id is new. + */ +const SHIPPED_LOCALE_DATA = asLocaleDataMap(shippedLocales, shippedLocaleItems); + +/** Completes one locale from the shipped locale of the same id, falling back to the default locale. */ +const resolveLocaleData = (id: KbqLocaleIdLike, data: KbqPartialLocaleData | undefined): KbqLocaleData => + kbqDeepMerge(SHIPPED_LOCALE_DATA[id] ?? SHIPPED_LOCALE_DATA[KBQ_DEFAULT_LOCALE_ID], data); + +const resolveLocaleDataMap = (input: KbqLocaleDataInput | null): KbqLocaleDataMap => { + const source = input ?? SHIPPED_LOCALE_DATA; + const items = source.items ?? SHIPPED_LOCALE_DATA.items; + const locales: Record = {}; + + // Every shipped locale and every offered `items` entry is registered, not just the ids the input + // happens to patch: a partial input leaves `items` at the full shipped list, and a locale a picker + // can activate must have data behind it — `setLocale` would otherwise leave `data()` undefined. + const ids = new Set([...Object.keys(SHIPPED_LOCALE_DATA), ...Object.keys(source), ...items.map(({ id }) => id)]); + + for (const id of ids) { + if (id === 'items') continue; + + locales[id] = resolveLocaleData(id, source[id] as KbqPartialLocaleData); + } + + return asLocaleDataMap(locales, items); +}; + export const KBQ_LOCALE_SERVICE = new InjectionToken('KBQ_LOCALE_SERVICE'); /** @@ -67,13 +119,49 @@ export const kbqLocaleServiceLangAttrNameProvider = (attrName: string): Provider @Injectable({ providedIn: 'root' }) export class KbqLocaleService { + /** + * Emits the active locale id on every change. + * + * Prefer the {@link localeId} signal in new code — a signal read from a template registers on the + * reading view, which an observable subscribed in one component cannot do for its children. + */ readonly changes: BehaviorSubject; - readonly locales: any = {}; + + /** Every registered locale keyed by id, plus the `items` list used to render locale pickers. */ + readonly locales: KbqLocaleDataMap; + + /** Active locale id. */ + readonly localeId: Signal; + + /** Locale data of the active locale. Always complete — see {@link addLocale}. */ + readonly data: Signal; + + /** Registered locales, for rendering a locale picker. */ + readonly items: Signal; private readonly document = inject(DOCUMENT); - id: string; - current; + /** @deprecated Use the {@link localeId} signal. */ + get id(): string { + return this._localeId(); + } + + set id(value: string) { + this._localeId.set(value); + } + + /** @deprecated Use the {@link data} signal. */ + get current(): KbqLocaleData { + return this._data(); + } + + set current(value: KbqLocaleData) { + this._data.set(value); + } + + private readonly _localeId = signal(KBQ_DEFAULT_LOCALE_ID); + private readonly _data = signal(SHIPPED_LOCALE_DATA[KBQ_DEFAULT_LOCALE_ID]); + private readonly _items = signal(SHIPPED_LOCALE_DATA.items); private readonly langAttrName = inject(KBQ_LOCALE_SERVICE_LANG_ATTR_NAME); @@ -81,32 +169,68 @@ export class KbqLocaleService { const id = inject(KBQ_LOCALE_ID, { optional: true }); const localeData = inject(KBQ_LOCALE_DATA, { optional: true }); - this.locales = localeData; + this.locales = resolveLocaleDataMap(localeData); - this.id = id || KBQ_DEFAULT_LOCALE_ID; - this.current = this.locales[this.id]; + this.localeId = this._localeId.asReadonly(); + this.data = this._data.asReadonly(); + this.items = this._items.asReadonly(); - this.changes = new BehaviorSubject(this.id); + this._localeId.set(id || KBQ_DEFAULT_LOCALE_ID); + this._data.set(this.register(this._localeId())); + this._items.set(this.locales.items); + + this.changes = new BehaviorSubject(this._localeId()); } - setLocale(id: string) { - this.id = id; - this.current = this.locales[this.id]; + /** Activates a locale. */ + setLocale(id: KbqLocaleIdLike) { + this._localeId.set(id); + this._data.set(this.register(id)); + + this.document.documentElement.setAttribute(this.langAttrName, id); - this.document.documentElement.setAttribute(this.langAttrName, this.id); + this.changes.next(id); + } - this.changes.next(this.id); + /** + * Registers a locale and activates it. + * + * The data may be partial: every section — and every key within a section — is optional, and whatever + * is omitted is completed from the shipped locale of the same id, or from the default locale when the + * id is new. That is what lets {@link getParams} promise a complete section for any registered locale. + */ + addLocale(id: KbqLocaleIdLike, localeData: KbqPartialLocaleData) { + this.locales[id] = resolveLocaleData(id, localeData); + + this.setLocale(id); } - addLocale(id: string, localeData) { - this.id = id; - this.changes.next(this.id); + /** + * Localized strings of one section of the active locale. + * + * Passing a known {@link KbqLocaleSection} resolves the precise configuration type; any other string + * falls back to `any`, so dynamically-built section names keep working. + */ + getParams(section: K): KbqLocaleData[K]; + getParams(section: string): any; + getParams(section: string) { + return this._data()?.[section] ?? SHIPPED_LOCALE_DATA[KBQ_DEFAULT_LOCALE_ID][section]; + } - this.locales[this.id] = localeData; + /** Reactive counterpart of {@link getParams}: re-emits whenever the locale changes. */ + params(section: K): Signal { + return computed(() => this.getParams(section)); } - getParams(componentName: string) { - return this.current[componentName]; + /** + * Registers `id` unless the registry already holds it, and returns its complete data. + * + * Both the constructor and {@link setLocale} activate a locale, and either can be handed an id the + * registry does not know — `KBQ_LOCALE_ID` accepts any string. {@link data} promises a complete locale, + * and code reading `locales[id]` directly would otherwise be handed `undefined`. + */ + private register(id: KbqLocaleIdLike): KbqLocaleData { + return (this.locales[id] ??= resolveLocaleData(id, undefined)); } } @@ -117,7 +241,7 @@ export const KBQ_DEFAULT_PRECISION_SEPARATOR = '.'; /** @docs-private */ export function numberByParts( value: string, - customConfig: Pick + customConfig: Pick ): { integer: string; fraction: string } { const { groupSeparator, fractionSeparator } = customConfig; const result = { integer: '', fraction: '' }; @@ -152,7 +276,7 @@ export function numberByParts( */ export function normalizeNumber( value: string | null | undefined, - customConfig: Pick + customConfig: Pick ): string { if (value === null || value === undefined) return ''; diff --git a/packages/components/core/locales/pt-BR.ts b/packages/components/core/locales/pt-BR.ts index 8cad608f86..5553949ad4 100644 --- a/packages/components/core/locales/pt-BR.ts +++ b/packages/components/core/locales/pt-BR.ts @@ -1,13 +1,4 @@ -import { - KbqA11yLocaleConfiguration, - KbqActionsPanelLocaleConfiguration, - KbqAppSwitcherConfiguration, - KbqClampedTextLocaleConfig, - KbqCodeBlockLocaleConfiguration, - KbqFileUploadLocaleConfig, - KbqSelectLocaleConfiguration, - KbqTimeRangeLocaleConfig -} from './types'; +import { KbqLocaleStringsData } from './types'; export const ptBRLocaleData = { a11y: { @@ -22,11 +13,11 @@ export const ptBRLocaleData = { clear: 'Apagar', showPassword: 'Mostrar a senha', hidePassword: 'Ocultar a senha' - } satisfies KbqA11yLocaleConfiguration, + }, select: { hiddenItemsText: '+{{ number }}', selectAll: 'Selecionar tudo' - } satisfies KbqSelectLocaleConfiguration, + }, datepicker: { placeholder: 'dd/mm/yyyy' }, @@ -56,7 +47,7 @@ export const ptBRLocaleData = { browseLinkFolderMixed: 'pasta', title: 'Carregar arquivos' } - } satisfies KbqFileUploadLocaleConfig, + }, codeBlock: { softWrapOnTooltip: 'Ativar quebra de linha', softWrapOffTooltip: 'Desativar quebra de linha', @@ -66,13 +57,13 @@ export const ptBRLocaleData = { viewAllText: 'Mostrar todos', viewLessText: 'Mostrar menos', openExternalSystemTooltip: 'Abrir em sistema externo' - } satisfies KbqCodeBlockLocaleConfiguration, + }, timezone: { searchPlaceholder: 'Cidade ou fuso horário' }, actionsPanel: { closeTooltip: 'Desmarcar' - } satisfies KbqActionsPanelLocaleConfiguration, + }, filterBar: { reset: { buttonName: 'Reconfigurar' @@ -129,7 +120,7 @@ export const ptBRLocaleData = { closeText: 'Recolher', showMoreText: 'Mostrar mais {exceededItemCount}', moreText: 'mais' - } satisfies KbqClampedTextLocaleConfig, + }, navbarIc: { toggle: { pinButton: 'Deixar expandido', @@ -151,7 +142,7 @@ export const ptBRLocaleData = { searchEmptyResult: 'Nada encontrado', sitesHeader: 'Outros sites', clearSearch: 'Limpar a pesquisa' - } satisfies KbqAppSwitcherConfiguration, + }, timeRange: { title: { for: 'para', @@ -240,7 +231,7 @@ export const ptBRLocaleData = { MONTHS_FRACTION: `{months} meses` } } - } satisfies KbqTimeRangeLocaleConfig, + }, notificationCenter: { notifications: 'Notificações', remove: 'Remover', @@ -251,4 +242,4 @@ export const ptBRLocaleData = { repeat: 'Repetir', loadingMore: 'Carregando mais notificações' } -}; +} satisfies KbqLocaleStringsData; diff --git a/packages/components/core/locales/ru-RU.ts b/packages/components/core/locales/ru-RU.ts index 39e7702567..17c8e88be9 100644 --- a/packages/components/core/locales/ru-RU.ts +++ b/packages/components/core/locales/ru-RU.ts @@ -1,13 +1,4 @@ -import { - KbqA11yLocaleConfiguration, - KbqActionsPanelLocaleConfiguration, - KbqAppSwitcherConfiguration, - KbqClampedTextLocaleConfig, - KbqCodeBlockLocaleConfiguration, - KbqFileUploadLocaleConfig, - KbqSelectLocaleConfiguration, - KbqTimeRangeLocaleConfig -} from './types'; +import { KbqLocaleStringsData } from './types'; export const ruRULocaleData = { a11y: { @@ -22,8 +13,8 @@ export const ruRULocaleData = { clear: 'Очистить', showPassword: 'Показать пароль', hidePassword: 'Скрыть пароль' - } satisfies KbqA11yLocaleConfiguration, - select: { hiddenItemsText: '+{{ number }}', selectAll: 'Выбрать все' } satisfies KbqSelectLocaleConfiguration, + }, + select: { hiddenItemsText: '+{{ number }}', selectAll: 'Выбрать все' }, datepicker: { placeholder: 'дд.мм.гггг', dateInput: 'dd.MM.yyyy' @@ -54,7 +45,7 @@ export const ruRULocaleData = { browseLinkFolderMixed: 'папку', title: 'Перетащите сюда' } - } satisfies KbqFileUploadLocaleConfig, + }, codeBlock: { softWrapOnTooltip: 'Включить перенос по словам', softWrapOffTooltip: 'Выключить перенос по словам', @@ -64,13 +55,13 @@ export const ruRULocaleData = { viewAllText: 'Показать все', viewLessText: 'Свернуть', openExternalSystemTooltip: 'Открыть во внешней системе' - } satisfies KbqCodeBlockLocaleConfiguration, + }, timezone: { searchPlaceholder: 'Город или часовой пояс' }, actionsPanel: { closeTooltip: 'Отменить выбор' - } satisfies KbqActionsPanelLocaleConfiguration, + }, filterBar: { reset: { buttonName: 'Сбросить' @@ -127,7 +118,7 @@ export const ruRULocaleData = { closeText: 'Свернуть', showMoreText: 'Показать еще {exceededItemCount}', moreText: 'еще' - } satisfies KbqClampedTextLocaleConfig, + }, navbarIc: { toggle: { pinButton: 'Оставить развернутым', @@ -149,7 +140,7 @@ export const ruRULocaleData = { searchEmptyResult: 'Ничего не найдено', sitesHeader: 'Другие площадки', clearSearch: 'Очистить поиск' - } satisfies KbqAppSwitcherConfiguration, + }, timeRange: { title: { for: 'за', @@ -252,7 +243,7 @@ export const ruRULocaleData = { MONTHS_FRACTION: `{months} месяцев` } } - } satisfies KbqTimeRangeLocaleConfig, + }, notificationCenter: { notifications: 'Уведомления', remove: 'Удалить', @@ -263,4 +254,4 @@ export const ruRULocaleData = { repeat: 'Повторить', loadingMore: 'Загрузка уведомлений' } -}; +} satisfies KbqLocaleStringsData; diff --git a/packages/components/core/locales/select.spec.ts b/packages/components/core/locales/select.spec.ts new file mode 100644 index 0000000000..d46ffc5803 --- /dev/null +++ b/packages/components/core/locales/select.spec.ts @@ -0,0 +1,89 @@ +import { TestBed } from '@angular/core/testing'; +import { BehaviorSubject } from 'rxjs'; +import { kbqInjectLocaleConfiguration } from './configuration'; +import { enUSLocaleData } from './en-US'; +import { KBQ_LOCALE_SERVICE, KbqLocaleService } from './locale-service'; +import { ruRULocaleData } from './ru-RU'; +import { + KBQ_SELECT_DEFAULT_LOCALE_CONFIGURATION, + KBQ_SELECT_LOCALE_CONFIGURATION, + kbqSelectLocaleConfigurationProvider +} from './select'; + +describe('KBQ_SELECT_LOCALE_CONFIGURATION', () => { + const inject = () => + TestBed.runInInjectionContext(() => kbqInjectLocaleConfiguration('select', KBQ_SELECT_LOCALE_CONFIGURATION)); + + it('should fall back to the default locale when no locale service is provided', () => { + TestBed.configureTestingModule({}); + + expect(KBQ_SELECT_DEFAULT_LOCALE_CONFIGURATION).toBe(ruRULocaleData.select); + expect(inject()()).toBe(ruRULocaleData.select); + }); + + it('should use the configuration provided through the injection token', () => { + const configuration = { ...ruRULocaleData.select, selectAll: 'Custom select all' }; + + TestBed.configureTestingModule({ + providers: [{ provide: KBQ_SELECT_LOCALE_CONFIGURATION, useValue: configuration }] + }); + + expect(inject()().selectAll).toBe('Custom select all'); + }); + + it('should apply the override on top of the active locale', () => { + TestBed.configureTestingModule({ + providers: [ + { provide: KBQ_LOCALE_SERVICE, useClass: KbqLocaleService }, + kbqSelectLocaleConfigurationProvider({ hiddenItemsText: 'and {{ number }} more' }) + ] + }); + + const configuration = inject(); + + expect(configuration().hiddenItemsText).toBe('and {{ number }} more'); + + TestBed.inject(KBQ_LOCALE_SERVICE).setLocale('en-US'); + + // The overridden string stays pinned; the rest of the section follows the locale. + expect(configuration().hiddenItemsText).toBe('and {{ number }} more'); + expect(configuration().selectAll).toBe(enUSLocaleData.select.selectAll); + }); + + it('should follow the locale service', () => { + TestBed.configureTestingModule({ + providers: [{ provide: KBQ_LOCALE_SERVICE, useClass: KbqLocaleService }] + }); + + const configuration = inject(); + + expect(configuration().selectAll).toBe(ruRULocaleData.select.selectAll); + + TestBed.inject(KBQ_LOCALE_SERVICE).setLocale('en-US'); + + expect(configuration().selectAll).toBe(enUSLocaleData.select.selectAll); + }); + + it('should get a complete section for locale data registered without one', () => { + TestBed.configureTestingModule({ + providers: [{ provide: KBQ_LOCALE_SERVICE, useClass: KbqLocaleService }] + }); + + const localeService = TestBed.inject(KBQ_LOCALE_SERVICE); + + // Locale data registered by a consumer may predate the section entirely; the service completes it. + localeService.addLocale('custom', { a11y: { close: 'Close' } }); + + expect(inject()()).toBe(ruRULocaleData.select); + }); + + it('should fall back when the locale service hands back no section at all', () => { + // `KbqLocaleService` itself always completes a section, but applications routinely provide a stand-in + // under `KBQ_LOCALE_SERVICE` in their own tests — that one is free to return nothing. + const stub = { changes: new BehaviorSubject('custom'), getParams: () => undefined }; + + TestBed.configureTestingModule({ providers: [{ provide: KBQ_LOCALE_SERVICE, useValue: stub }] }); + + expect(inject()()).toBe(ruRULocaleData.select); + }); +}); diff --git a/packages/components/core/locales/select.ts b/packages/components/core/locales/select.ts new file mode 100644 index 0000000000..3a61e48930 --- /dev/null +++ b/packages/components/core/locales/select.ts @@ -0,0 +1,29 @@ +import { InjectionToken, Provider } from '@angular/core'; +import { KbqDeepPartial } from '../utils'; +import { kbqLocaleConfigurationOverrideProvider } from './configuration'; +import { ruRULocaleData } from './ru-RU'; +import { KbqSelectLocaleConfiguration } from './types'; + +/** Default localized strings shared by the select-like components. */ +export const KBQ_SELECT_DEFAULT_LOCALE_CONFIGURATION: KbqSelectLocaleConfiguration = ruRULocaleData.select; + +/** + * Localization configuration provider for the select-like components. + * + * Lives in `core` rather than in `@koobiq/components/select` because three packages that do not depend on + * one another read this section: `KbqSelect`, `KbqTreeSelect` and `KbqTreeSelection` (the last one also + * standalone, outside a tree-select). + */ +export const KBQ_SELECT_LOCALE_CONFIGURATION = new InjectionToken( + 'KbqSelectLocaleConfiguration', + { factory: () => KBQ_SELECT_DEFAULT_LOCALE_CONFIGURATION } +); + +/** + * Utility provider. Only the strings you pass are overridden; the rest keep following the active locale. + * + * @see KBQ_SELECT_LOCALE_CONFIGURATION + */ +export const kbqSelectLocaleConfigurationProvider = ( + configuration: KbqDeepPartial +): Provider => kbqLocaleConfigurationOverrideProvider('select', configuration); diff --git a/packages/components/core/locales/tk-TM.ts b/packages/components/core/locales/tk-TM.ts index d99ba46f63..0f125adc05 100644 --- a/packages/components/core/locales/tk-TM.ts +++ b/packages/components/core/locales/tk-TM.ts @@ -1,13 +1,4 @@ -import { - KbqA11yLocaleConfiguration, - KbqActionsPanelLocaleConfiguration, - KbqAppSwitcherConfiguration, - KbqClampedTextLocaleConfig, - KbqCodeBlockLocaleConfiguration, - KbqFileUploadLocaleConfig, - KbqSelectLocaleConfiguration, - KbqTimeRangeLocaleConfig -} from './types'; +import { KbqLocaleStringsData } from './types'; export const tkTMLocaleData = { a11y: { @@ -22,11 +13,11 @@ export const tkTMLocaleData = { clear: 'Arassala', showPassword: 'Paroly görkez', hidePassword: 'Paroly gizle' - } satisfies KbqA11yLocaleConfiguration, + }, select: { hiddenItemsText: '+{{ number }}', selectAll: 'Ählisini saýla' - } satisfies KbqSelectLocaleConfiguration, + }, datepicker: { placeholder: 'gg.aa.ýý.', dateInput: 'gg.aa.ýý.' @@ -57,7 +48,7 @@ export const tkTMLocaleData = { browseLinkFolderMixed: 'bukja', title: 'Faýl ýükläň' } - } satisfies KbqFileUploadLocaleConfig, + }, codeBlock: { softWrapOnTooltip: 'Sözler boýunça geçirmäni işjeňleşdirmek', softWrapOffTooltip: 'Sözler boýunça geçirmäni öçürmek', @@ -67,13 +58,13 @@ export const tkTMLocaleData = { viewAllText: 'Hemmesini görkezmek', viewLessText: 'Ýygyrmak', openExternalSystemTooltip: 'Daşarky ulgamda açmak' - } satisfies KbqCodeBlockLocaleConfiguration, + }, timezone: { searchPlaceholder: 'Şäher ýa-da sagat guşagy' }, actionsPanel: { closeTooltip: 'Saýlawy ýatyr' - } satisfies KbqActionsPanelLocaleConfiguration, + }, filterBar: { reset: { buttonName: 'Täzeden düz' @@ -130,7 +121,7 @@ export const tkTMLocaleData = { closeText: 'Ýap', showMoreText: 'Ýene {exceededItemCount} görkeziň', moreText: 'has köp' - } satisfies KbqClampedTextLocaleConfig, + }, navbarIc: { toggle: { pinButton: 'Rugsat giňeldildi', @@ -152,7 +143,7 @@ export const tkTMLocaleData = { searchEmptyResult: 'Hiç zat tapylmady', sitesHeader: 'Beýleki saýtlar', clearSearch: 'Gözlegi arassala' - } satisfies KbqAppSwitcherConfiguration, + }, timeRange: { title: { for: 'soňky', @@ -241,7 +232,7 @@ export const tkTMLocaleData = { MONTHS_FRACTION: `{months} aý` } } - } satisfies KbqTimeRangeLocaleConfig, + }, notificationCenter: { notifications: 'Duýduryşlar', remove: 'Aýyr', @@ -252,4 +243,4 @@ export const tkTMLocaleData = { repeat: 'Gaýtalama', loadingMore: 'Duýduryşlar ýüklenýär' } -}; +} satisfies KbqLocaleStringsData; diff --git a/packages/components/core/locales/types.ts b/packages/components/core/locales/types.ts index 7af586899d..0fecd833e1 100644 --- a/packages/components/core/locales/types.ts +++ b/packages/components/core/locales/types.ts @@ -1,4 +1,7 @@ import { FormatterDurationTemplate } from '@koobiq/date-formatter'; +// Type-only: `core/formatters` imports the locale data back, and a value import here would close the cycle. +import type { KbqSizeUnitsConfig } from '../formatters'; +import { KbqDeepPartial } from '../utils'; /** * Accessible names for the icon-only buttons the library renders itself. @@ -67,7 +70,7 @@ export type KbqSelectLocaleConfiguration = { }; /** Locale configuration for `KbqAppSwitcherModule`: the strings rendered by the app-switcher popup. */ -export type KbqAppSwitcherConfiguration = { +export type KbqAppSwitcherLocaleConfiguration = { /** Placeholder and accessible name of the search field. */ searchPlaceholder: string; /** Message shown when no application matches the search query. */ @@ -78,6 +81,148 @@ export type KbqAppSwitcherConfiguration = { clearSearch: string; }; +/** + * Locale configuration for `KbqDatepickerModule`. + * + * Only `placeholder` reaches the rendered output — see `dateInput`. + */ +export type KbqDatepickerLocaleConfiguration = { + /** Placeholder of the date input, in the locale's own notation (e.g. `дд.мм.гггг`). */ + placeholder: string; + /** + * Parsing/formatting pattern for the date input. + * + * Never read: the input format is resolved from `KBQ_DATE_FORMATS` and the date adapter's own config, + * not from the locale data. Optional because `es-LA` and `pt-BR` never declared it. Scheduled for + * removal in a future major version — do not start depending on it. + */ + dateInput?: string; +}; + +/** Locale configuration for `KbqTimepickerModule`. */ +export type KbqTimepickerLocaleConfiguration = { + /** Placeholders keyed by the time format rendered by the input. */ + placeholder: { + /** Placeholder for the `HH:mm:ss` format. */ + full: string; + /** Placeholder for the `HH:mm` format. */ + short: string; + }; +}; + +/** Locale configuration for `KbqTimezoneModule`. */ +export type KbqTimezoneLocaleConfiguration = { + /** Placeholder of the search field inside the timezone select. */ + searchPlaceholder: string; +}; + +/** Locale configuration for the `kbq-vertical-navbar` collapse toggle. */ +export type KbqNavbarLocaleConfiguration = { + toggle: { + /** Accessible name of the toggle while the navbar is collapsed. */ + expand: string; + /** Accessible name of the toggle while the navbar is expanded. */ + collapse: string; + }; +}; + +/** + * Locale configuration for the information-carrier navbar toggle. + * + * No component reads this section — it is shipped for backwards compatibility and is scheduled for + * removal in a future major version. Do not start depending on it. + */ +export type KbqNavbarIcLocaleConfiguration = { + toggle: { + pinButton: string; + collapseButton: string; + }; +}; + +/** Locale configuration for `KbqSearchExpandableModule`. */ +export type KbqSearchExpandableLocaleConfiguration = { + /** Accessible name and tooltip of the collapsed search trigger. */ + tooltip: string; + /** Placeholder of the expanded search field. */ + placeholder: string; +}; + +/** Locale configuration for `KbqNotificationCenterModule`. */ +export type KbqNotificationCenterLocaleConfiguration = { + /** Heading of the notification center. */ + notifications: string; + /** Accessible name of a single notification's remove button. */ + remove: string; + /** Label of the "do not disturb" switch. */ + doNotDisturb: string; + /** Label of the "show pop-up notifications" switch. */ + showPopUpNotifications: string; + /** Message shown when there is nothing to display. */ + noNotifications: string; + /** Message shown when loading the notifications failed. */ + failedToLoadNotifications: string; + /** Label of the button retrying a failed load. */ + repeat: string; + /** Announced while the next page of notifications is loading. */ + loadingMore: string; +}; + +/** Locale configuration for `KbqFilterBarModule` and its pipes. */ +export type KbqFilterBarLocaleConfiguration = { + reset: { + buttonName: string; + }; + search: { + tooltip: string; + placeholder: string; + }; + filters: { + defaultName: string; + saveNewFilterTooltip: string; + searchPlaceholder: string; + searchEmptyResult: string; + saveAsNewFilter: string; + saveChanges: string; + saveAsNew: string; + change: string; + resetChanges: string; + remove: string; + name: string; + error: string; + errorHint: string; + saveButton: string; + cancelButton: string; + actionsTooltip: string; + }; + add: { + tooltip: string; + /** Announced after a filter is added. Supports the `{{ name }}` placeholder. */ + addedAnnouncement: string; + }; + refresher: { + refresh: string; + settings: string; + }; + pipe: { + clearButtonTooltip: string; + removeButtonTooltip: string; + applyButton: string; + emptySearchResult: string; + selectAll: string; + }; + datePipe: { + customPeriod: string; + customPeriodFrom: string; + customPeriodTo: string; + customPeriodErrorHint: string; + /** Supports the `{{ value }}` placeholder. */ + customPeriodMinIntervalErrorHint: string; + /** Supports the `{{ value }}` placeholder. */ + customPeriodMaxIntervalErrorHint: string; + backToPeriodSelection: string; + }; +}; + /** Options for overriding locale-based number formatting */ export type KbqNumberFormatOptions = { /** Overrides the default group separator in the formatted output */ @@ -91,7 +236,7 @@ export type KbqNumberFormatOptions = { * formatting options. * @docs-private */ -export type KbqNumberRoundingLocaleConfig = { +export type KbqNumberRoundingLocaleConfiguration = { /** Separator placed between the number and its rounding unit label. */ separator: string; /** Separator placed between the integer and fractional parts. */ @@ -107,7 +252,7 @@ export type KbqNumberRoundingLocaleConfig = { }; /** Locale configuration for `KbqNumberInput`. */ -export type KbqNumberInputLocaleConfig = { +export type KbqNumberInputLocaleConfiguration = { /** Characters recognized as group (thousands) separators. */ groupSeparator: string[]; /** Character used for the decimal separator */ @@ -116,8 +261,22 @@ export type KbqNumberInputLocaleConfig = { startFormattingFrom?: number; } & KbqNumberFormatOptions; +/** Locale configuration for the number formatter pipes. */ +export type KbqNumberFormattersLocaleConfiguration = { + number: { + rounding: KbqNumberRoundingLocaleConfiguration; + /** Present only for the locales that override the group separator of the decimal pipe. */ + decimal?: KbqNumberFormatOptions; + }; +}; + +/** Locale configuration for the library's inputs. */ +export type KbqInputLocaleConfiguration = { + number: KbqNumberInputLocaleConfiguration; +}; + /** Locale configuration for `KbqClampedText` */ -export type KbqClampedTextLocaleConfig = { +export type KbqClampedTextLocaleConfiguration = { openText: string; closeText: string; showMoreText: string; @@ -125,7 +284,7 @@ export type KbqClampedTextLocaleConfig = { }; /** Locale configuration for `KbqTimeRange` */ -export type KbqTimeRangeLocaleConfig = { +export type KbqTimeRangeLocaleConfiguration = { title: { for: string; placeholder: string; @@ -152,7 +311,7 @@ export type KbqTimeRangeLocaleConfig = { }; }; -export interface KbqBaseFileUploadLocaleConfig { +export interface KbqBaseFileUploadLocaleConfiguration { captionText: string; captionTextOnlyFolder: string; captionTextWithFolder: string; @@ -161,13 +320,112 @@ export interface KbqBaseFileUploadLocaleConfig { browseLinkFolderMixed?: string; } -export interface KbqMultipleFileUploadLocaleConfig extends KbqBaseFileUploadLocaleConfig { +export interface KbqMultipleFileUploadLocaleConfiguration extends KbqBaseFileUploadLocaleConfiguration { captionTextWhenSelected: string; captionTextForCompactSize: string; title: string; } -export type KbqFileUploadLocaleConfig = { - single: KbqBaseFileUploadLocaleConfig; - multiple: KbqMultipleFileUploadLocaleConfig; +export type KbqFileUploadLocaleConfiguration = { + single: KbqBaseFileUploadLocaleConfiguration; + multiple: KbqMultipleFileUploadLocaleConfiguration; }; + +/** + * The localized strings of a locale — the shape of `ruRULocaleData` and its siblings. + * + * Split from {@link KbqLocaleFormattersData} because the two halves live in separate files and are + * merged into one locale entry by `KBQ_DEFAULT_LOCALE_DATA_FACTORY`. + */ +export interface KbqLocaleStringsData { + a11y: KbqA11yLocaleConfiguration; + select: KbqSelectLocaleConfiguration; + datepicker: KbqDatepickerLocaleConfiguration; + timepicker: KbqTimepickerLocaleConfiguration; + fileUpload: KbqFileUploadLocaleConfiguration; + codeBlock: KbqCodeBlockLocaleConfiguration; + timezone: KbqTimezoneLocaleConfiguration; + actionsPanel: KbqActionsPanelLocaleConfiguration; + filterBar: KbqFilterBarLocaleConfiguration; + clampedText: KbqClampedTextLocaleConfiguration; + navbarIc: KbqNavbarIcLocaleConfiguration; + navbar: KbqNavbarLocaleConfiguration; + searchExpandable: KbqSearchExpandableLocaleConfiguration; + appSwitcher: KbqAppSwitcherLocaleConfiguration; + timeRange: KbqTimeRangeLocaleConfiguration; + notificationCenter: KbqNotificationCenterLocaleConfiguration; +} + +/** The number and size formatting rules of a locale — the shape of `ruRUFormattersData` and its siblings. */ +export interface KbqLocaleFormattersData { + formatters: KbqNumberFormattersLocaleConfiguration; + input: KbqInputLocaleConfiguration; + sizeUnits: KbqSizeUnitsConfig; +} + +/** + * Every localized string and formatting rule the library reads, keyed by section. + * + * This is the contract for custom locale data registered through `KBQ_LOCALE_DATA` or + * `KbqLocaleService.addLocale()`, and the shape `KbqLocaleService.getParams()` resolves against. + */ +export interface KbqLocaleData extends KbqLocaleStringsData, KbqLocaleFormattersData {} + +/** Name of a section of {@link KbqLocaleData}, as accepted by `KbqLocaleService.getParams()`. */ +export type KbqLocaleSection = keyof KbqLocaleData; + +/** Identifiers of the locales shipped with the library. */ +export type KbqLocaleId = 'en-US' | 'es-LA' | 'pt-BR' | 'ru-RU' | 'tk-TM'; + +/** + * A locale identifier: one of the shipped {@link KbqLocaleId}s, or any other string for a locale + * registered through `KBQ_LOCALE_DATA` / `addLocale()`. The `string & {}` arm keeps the set open while + * still offering the shipped ids as completions. + */ +export type KbqLocaleIdLike = KbqLocaleId | (string & {}); + +/** An entry of the locale registry, used to render locale pickers. */ +export type KbqLocaleItem = { + id: KbqLocaleIdLike; + /** Name of the locale in that locale's own language. */ + name: string; +}; + +/** The resolved locale registry: every known locale keyed by id, plus the list used by locale pickers. */ +export type KbqLocaleDataMap = Record & { items: KbqLocaleItem[] }; + +/** Locale data with every section — and every key within a section — optional. */ +export type KbqPartialLocaleData = KbqDeepPartial; + +/** + * Shape accepted by `KBQ_LOCALE_DATA`. Each locale may be partial: `KbqLocaleService` merges what it + * receives over the default locale, so a consumer only has to supply the strings they want to change. + */ +export interface KbqLocaleDataInput { + items?: KbqLocaleItem[]; + [localeId: string]: KbqPartialLocaleData | KbqLocaleItem[] | undefined; +} + +/** @deprecated Use {@link KbqAppSwitcherLocaleConfiguration}. */ +export type KbqAppSwitcherConfiguration = KbqAppSwitcherLocaleConfiguration; + +/** @deprecated Use {@link KbqClampedTextLocaleConfiguration}. */ +export type KbqClampedTextLocaleConfig = KbqClampedTextLocaleConfiguration; + +/** @deprecated Use {@link KbqTimeRangeLocaleConfiguration}. */ +export type KbqTimeRangeLocaleConfig = KbqTimeRangeLocaleConfiguration; + +/** @deprecated Use {@link KbqNumberRoundingLocaleConfiguration}. */ +export type KbqNumberRoundingLocaleConfig = KbqNumberRoundingLocaleConfiguration; + +/** @deprecated Use {@link KbqNumberInputLocaleConfiguration}. */ +export type KbqNumberInputLocaleConfig = KbqNumberInputLocaleConfiguration; + +/** @deprecated Use {@link KbqBaseFileUploadLocaleConfiguration}. */ +export type KbqBaseFileUploadLocaleConfig = KbqBaseFileUploadLocaleConfiguration; + +/** @deprecated Use {@link KbqMultipleFileUploadLocaleConfiguration}. */ +export type KbqMultipleFileUploadLocaleConfig = KbqMultipleFileUploadLocaleConfiguration; + +/** @deprecated Use {@link KbqFileUploadLocaleConfiguration}. */ +export type KbqFileUploadLocaleConfig = KbqFileUploadLocaleConfiguration; diff --git a/packages/components/core/utils/utils.ts b/packages/components/core/utils/utils.ts index bd2c373cf6..0b4e184464 100644 --- a/packages/components/core/utils/utils.ts +++ b/packages/components/core/utils/utils.ts @@ -43,3 +43,48 @@ export function isMac(): boolean { /** Converts an enumeration (enum) type into a string literal type containing * all possible string representations of the values. */ export type KbqEnumValues = `${T}`; + +/** + * Recursive counterpart of `Partial`: every property at every depth becomes optional. + * + * Arrays and functions are passed through unchanged — making the elements of `string[]` optional + * would turn it into `{ 0?: string }`, which is never what a partial override means. + */ +export type KbqDeepPartial = T extends (...args: never[]) => unknown + ? T + : T extends readonly unknown[] + ? T + : T extends object + ? { [K in keyof T]?: KbqDeepPartial } + : T; + +const isMergeableObject = (value: unknown): value is Record => + !!value && typeof value === 'object' && !Array.isArray(value); + +/** + * Recursively completes `patch` from `base` — the runtime counterpart of {@link KbqDeepPartial}. + * + * A shallow spread would be wrong for any `T` with a nested section: `{ ...base, ...patch }` replaces a + * whole sub-object, dropping the sibling keys the patch never mentioned. + * + * Returns `base` itself whenever the patch adds nothing, so that overriding one section leaves every + * other section referentially identical to the object it was completed from. + */ +export const kbqDeepMerge = (base: T, patch: NoInfer> | undefined): T => { + if (patch === undefined) return base; + if (!isMergeableObject(base) || !isMergeableObject(patch)) return patch as T; + + const result: Record = { ...base }; + let changed = false; + + for (const key of Object.keys(patch)) { + const merged = kbqDeepMerge(base[key], patch[key]); + + if (merged !== result[key]) { + result[key] = merged; + changed = true; + } + } + + return (changed ? result : base) as T; +}; diff --git a/packages/components/datepicker/datepicker-input.directive.ts b/packages/components/datepicker/datepicker-input.directive.ts index 98550a539e..a14840997e 100644 --- a/packages/components/datepicker/datepicker-input.directive.ts +++ b/packages/components/datepicker/datepicker-input.directive.ts @@ -3,6 +3,7 @@ import { AfterContentInit, Directive, DoCheck, + effect, ElementRef, EventEmitter, forwardRef, @@ -11,9 +12,9 @@ import { Input, OnDestroy, output, + Provider, Renderer2 } from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { AbstractControl, ControlValueAccessor, @@ -41,9 +42,12 @@ import { isLetterKey, isVerticalMovement, KBQ_DATE_FORMATS, - KBQ_LOCALE_SERVICE, KbqDateFormats, + KbqDatepickerLocaleConfiguration, + KbqDeepPartial, KbqErrorStateTracker, + kbqInjectLocaleConfiguration, + kbqLocaleConfigurationOverrideProvider, LEFT_ARROW, PAGE_DOWN, PAGE_UP, @@ -186,7 +190,18 @@ export const KBQ_DATEPICKER_DEFAULT_CONFIGURATION = ruRULocaleData.datepicker; /** Injection Token for providing configuration of datepicker */ /** @docs-private */ -export const KBQ_DATEPICKER_CONFIGURATION = new InjectionToken('KbqDatepickerConfiguration'); +export const KBQ_DATEPICKER_CONFIGURATION = new InjectionToken( + 'KbqDatepickerConfiguration', + { factory: () => KBQ_DATEPICKER_DEFAULT_CONFIGURATION } +); + +/** + * Utility provider for `KBQ_DATEPICKER_CONFIGURATION`. Only the strings you pass are overridden; the rest + * keep following the active locale. + */ +export const kbqDatepickerLocaleConfigurationProvider = ( + configuration: KbqDeepPartial +): Provider => kbqLocaleConfigurationOverrideProvider('datepicker', configuration); /** * An event used for datepicker input and change events. We don't always have access to a native @@ -252,11 +267,12 @@ export class KbqDatepickerInput private readonly dateFormats = inject(KBQ_DATE_FORMATS, { optional: true }); /** @docs-private */ protected readonly formField = inject(KBQ_FORM_FIELD, { optional: true, host: true }); - /** @docs-private */ - protected readonly localeService = inject(KBQ_LOCALE_SERVICE, { optional: true }); - /** @docs-private */ - protected readonly externalConfiguration = inject(KBQ_DATEPICKER_CONFIGURATION, { optional: true }); - protected configuration; + + protected get configuration(): KbqDatepickerLocaleConfiguration { + return this._configuration(); + } + + private readonly _configuration = kbqInjectLocaleConfiguration('datepicker', KBQ_DATEPICKER_CONFIGURATION); readonly stateChanges: Subject = new Subject(); @@ -576,11 +592,25 @@ export class KbqDatepickerInput this.setFormat(this.dateInputFormat); - this.localeService?.changes.pipe(takeUntilDestroyed()).subscribe(this.updateLocaleParams); + let isFirstRun = true; - if (!this.localeService) { - this.initDefaultParams(); - } + effect(() => { + this._configuration(); + + // Nothing to re-format on the first run: `setFormat` above already ran against the active + // locale, while re-assigning `value` here would emit `valueChange` at a point where the + // datepicker and the calendar are already subscribed to it. + if (isFirstRun) { + isFirstRun = false; + + return; + } + + // The date adapter follows the same locale, so its input format may have changed with it: the + // digit layout has to be re-derived and the rendered value re-formatted. + this.setFormat(this.dateInputFormat); + this.value = this.value; + }); } ngDoCheck() { @@ -878,18 +908,6 @@ export class KbqDatepickerInput return this.adapter.createDateTime(years, month, day, hours, minutes, seconds, milliseconds); } - private updateLocaleParams = () => { - this.setFormat(this.dateInputFormat); - - this.configuration = this.externalConfiguration || this.localeService?.getParams('datepicker'); - - this.value = this.value; - }; - - private initDefaultParams() { - this.configuration = this.externalConfiguration || KBQ_DATEPICKER_DEFAULT_CONFIGURATION; - } - private setFormat(format: string): void { // `[a-zA-Z]`, not `[aA-zZ]`: the latter reads as `a`, the range `A-z` and `Z`, and that range // takes in the six characters ASCII puts between the two alphabets — opening bracket, diff --git a/packages/components/filter-bar/filter-bar.spec.ts b/packages/components/filter-bar/filter-bar.spec.ts index f32199ab4c..4228b56af4 100644 --- a/packages/components/filter-bar/filter-bar.spec.ts +++ b/packages/components/filter-bar/filter-bar.spec.ts @@ -10,6 +10,7 @@ import { KbqFilter, KbqFilterBar, KbqFilterBarConfiguration, + kbqFilterBarLocaleConfigurationProvider, KbqFilterBarModule, KbqPipe, KbqPipeTemplate, @@ -631,10 +632,11 @@ describe('KbqFilterBar', () => { }); }); - // Precedence implemented by KbqFilterBar.updateLocaleParams: - // configuration = externalConfiguration (KBQ_FILTER_BAR_CONFIGURATION) || localeService.getParams('filterBar') - // The subscription to KBQ_LOCALE_SERVICE.changes re-runs updateLocaleParams on every locale emission. - describe('locale-change / externalConfiguration precedence', () => { + // Precedence implemented by `kbqInjectLocaleConfiguration`: + // configuration = overrides (kbqFilterBarLocaleConfigurationProvider) merged on top of + // localeService.getParams('filterBar'), falling back to KBQ_FILTER_BAR_CONFIGURATION when no locale + // service is provided. The configuration signal re-emits on every KBQ_LOCALE_SERVICE.changes emission. + describe('locale-change / configuration-override precedence', () => { // Minimal stand-in for KbqLocaleService: a BehaviorSubject-backed `changes` stream plus // `getParams`, returning a distinct configuration per locale id so swaps are observable. class MockLocaleService { @@ -686,20 +688,20 @@ describe('KbqFilterBar', () => { // Initial locale ('locale-a') is applied via the BehaviorSubject's replayed value. expect(filterBar.configuration.filters.defaultName).toBe('Locale A name'); - // Switching the locale must re-run updateLocaleParams and swap the configuration. + // Switching the locale must re-emit the configuration signal. localeService.setLocale('locale-b'); expect(filterBar.configuration.filters.defaultName).toBe('Locale B name'); }); - it('should let externalConfiguration win over the locale service', () => { + it('should let a registered override win over the locale service', () => { const localeService = new MockLocaleService(); TestBed.configureTestingModule({ imports: [NoopAnimationsModule, KbqFilterBarModule, TestComponent], providers: [ { provide: KBQ_LOCALE_SERVICE, useValue: localeService }, - { provide: KBQ_FILTER_BAR_CONFIGURATION, useValue: externalConfiguration } + kbqFilterBarLocaleConfigurationProvider({ filters: { defaultName: 'External name' } }) ] }); @@ -710,12 +712,30 @@ describe('KbqFilterBar', () => { const filterBar = localFixture.debugElement.query(By.directive(KbqFilterBar)) .componentInstance as KbqFilterBar; - // externalConfiguration takes precedence over the locale-provided params. + // The override is merged on top of the locale-provided params. expect(filterBar.configuration.filters.defaultName).toBe('External name'); + expect(filterBar.configuration.reset.buttonName).toBe('Locale A reset'); - // A locale change must NOT override the external configuration. + // A locale change must NOT drop the override, and must still move everything it left alone. localeService.setLocale('locale-b'); + expect(filterBar.configuration.filters.defaultName).toBe('External name'); + expect(filterBar.configuration.reset.buttonName).toBe('Locale B reset'); + }); + + it('should take the strings from KBQ_FILTER_BAR_CONFIGURATION when no locale service is provided', () => { + TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, KbqFilterBarModule, TestComponent], + providers: [{ provide: KBQ_FILTER_BAR_CONFIGURATION, useValue: externalConfiguration }] + }); + + const localFixture = TestBed.createComponent(TestComponent); + + localFixture.detectChanges(); + + const filterBar = localFixture.debugElement.query(By.directive(KbqFilterBar)) + .componentInstance as KbqFilterBar; + expect(filterBar.configuration.filters.defaultName).toBe('External name'); }); diff --git a/packages/components/filter-bar/filter-bar.ts b/packages/components/filter-bar/filter-bar.ts index b2ac8d1ff6..b6e42631fd 100644 --- a/packages/components/filter-bar/filter-bar.ts +++ b/packages/components/filter-bar/filter-bar.ts @@ -1,26 +1,22 @@ import { booleanAttribute, ChangeDetectionStrategy, - ChangeDetectorRef, Component, computed, contentChild, effect, forwardRef, - inject, input, model, output, - signal, ViewEncapsulation } from '@angular/core'; import { outputToObservable, takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { KBQ_LOCALE_SERVICE } from '@koobiq/components/core'; +import { kbqInjectLocaleConfiguration } from '@koobiq/components/core'; import { KbqDividerModule } from '@koobiq/components/divider'; import { BehaviorSubject } from 'rxjs'; import { KBQ_FILTER_BAR_CONFIGURATION, - KBQ_FILTER_BAR_DEFAULT_CONFIGURATION, KBQ_FILTER_BAR_HOST, KbqFilter, KbqFilterBarConfiguration, @@ -60,23 +56,17 @@ import { KbqFilters } from './filters'; } }) export class KbqFilterBar implements KbqFilterBarHost { - /** @docs-private */ - protected readonly changeDetectorRef = inject(ChangeDetectorRef); - /** @docs-private */ - protected readonly localeService = inject(KBQ_LOCALE_SERVICE, { optional: true }); - - readonly externalConfiguration = inject(KBQ_FILTER_BAR_CONFIGURATION, { optional: true }); - - /** Localized strings and configuration for the filter-bar and its pipes. */ + /** + * Localized strings and configuration for the filter-bar and its pipes. + * + * Read through a signal so that a runtime `setLocale()` reaches the pipes and the projected + * sub-components, which render these strings from their own `OnPush` views. + */ get configuration(): KbqFilterBarConfiguration { return this._configuration(); } - set configuration(value: KbqFilterBarConfiguration) { - this._configuration.set(value); - } - - private readonly _configuration = signal(KBQ_FILTER_BAR_DEFAULT_CONFIGURATION); + private readonly _configuration = kbqInjectLocaleConfiguration('filterBar', KBQ_FILTER_BAR_CONFIGURATION); /** @docs-private */ readonly filters = contentChild(KbqFilters); @@ -152,12 +142,6 @@ export class KbqFilterBar implements KbqFilterBarHost { this.filter.set(filter); }); - this.localeService?.changes.pipe(takeUntilDestroyed()).subscribe(this.updateLocaleParams); - - if (!this.localeService) { - this.initDefaultParams(); - } - // A pipe value change marks the current filter as "changed". Produce a new filter reference (not an // in-place mutation) so the `filter` model — and every `computed()`/`effect()` reading it — reacts. // `removePipe` owns its own `changed` flag in a single `set` (one `filterChange` emission), so only @@ -221,14 +205,4 @@ export class KbqFilterBar implements KbqFilterBarHost { this.filter.set({ ...current, changed: false }); } - - private updateLocaleParams = () => { - this.configuration = this.externalConfiguration || this.localeService?.getParams('filterBar'); - - this.changeDetectorRef.markForCheck(); - }; - - private initDefaultParams() { - this.configuration = KBQ_FILTER_BAR_DEFAULT_CONFIGURATION; - } } diff --git a/packages/components/filter-bar/filter-bar.types.ts b/packages/components/filter-bar/filter-bar.types.ts index d06347b915..0a426df919 100644 --- a/packages/components/filter-bar/filter-bar.types.ts +++ b/packages/components/filter-bar/filter-bar.types.ts @@ -1,5 +1,10 @@ import { InjectionToken, ModelSignal, OutputEmitterRef, Provider, Signal, TemplateRef, Type } from '@angular/core'; -import { KbqPanelMaxHeight, ruRULocaleData } from '@koobiq/components/core'; +import { + KbqDeepPartial, + kbqLocaleConfigurationOverrideProvider, + KbqPanelMaxHeight, + ruRULocaleData +} from '@koobiq/components/core'; import { BehaviorSubject } from 'rxjs'; import { KbqFilterBar } from './filter-bar'; import type { KbqBasePipe } from './pipes/base-pipe'; @@ -25,7 +30,17 @@ export const KBQ_FILTER_BAR_DEFAULT_CONFIGURATION = ruRULocaleData.filterBar; export type KbqFilterBarConfiguration = typeof KBQ_FILTER_BAR_DEFAULT_CONFIGURATION; /** Injection Token for providing configuration of filter-bar */ -export const KBQ_FILTER_BAR_CONFIGURATION = new InjectionToken('KbqFilterBarConfiguration'); +export const KBQ_FILTER_BAR_CONFIGURATION = new InjectionToken('KbqFilterBarConfiguration', { + factory: () => KBQ_FILTER_BAR_DEFAULT_CONFIGURATION +}); + +/** + * Utility provider for `KBQ_FILTER_BAR_CONFIGURATION`. Only the strings you pass are overridden; the rest + * keep following the active locale. + */ +export const kbqFilterBarLocaleConfigurationProvider = ( + configuration: KbqDeepPartial +): Provider => kbqLocaleConfigurationOverrideProvider('filterBar', configuration); /** * Contract a pipe (or filter-bar sub-component) depends on instead of the concrete `KbqFilterBar`. @@ -34,8 +49,11 @@ export const KBQ_FILTER_BAR_CONFIGURATION = new InjectionToken; diff --git a/packages/components/filter-bar/filters.spec.ts b/packages/components/filter-bar/filters.spec.ts index 834da42d68..cbba919f85 100644 --- a/packages/components/filter-bar/filters.spec.ts +++ b/packages/components/filter-bar/filters.spec.ts @@ -4,6 +4,7 @@ import { ComponentFixture, fakeAsync, flush, TestBed } from '@angular/core/testi import { By } from '@angular/platform-browser'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { KbqButton } from '@koobiq/components/button'; +import { enUSLocaleData, KBQ_LOCALE_SERVICE, KbqLocaleService } from '@koobiq/components/core'; import { KBQ_FILTER_BAR_DEFAULT_CONFIGURATION, KbqFilter, @@ -525,38 +526,37 @@ describe('KbqFilters', () => { }); it('should re-derive filterSavingErrorText from live configuration, not a one-time snapshot', () => { + TestBed.configureTestingModule({ + providers: [{ provide: KBQ_LOCALE_SERVICE, useClass: KbqLocaleService }] + }); + initFixture(); const component = getFiltersComponent(); - const filterBar = getFilterBar(); component.showError(); expect(component.filterSavingErrorText).toBe(KBQ_FILTER_BAR_DEFAULT_CONFIGURATION.filters.errorHint); - // Equivalent to a runtime locale switch: `updateLocaleParams` ends by assigning `configuration` - // through this same setter. The text must follow the new locale, not stay frozen at the value - // `showError` happened to see. - filterBar.configuration = { - ...KBQ_FILTER_BAR_DEFAULT_CONFIGURATION, - filters: { ...KBQ_FILTER_BAR_DEFAULT_CONFIGURATION.filters, errorHint: 'Locale B error hint' } - }; + // The text must follow a runtime locale switch, not stay frozen at the value `showError` + // happened to see. + TestBed.inject(KBQ_LOCALE_SERVICE).setLocale('en-US'); - expect(component.filterSavingErrorText).toBe('Locale B error hint'); + expect(component.filterSavingErrorText).toBe(enUSLocaleData.filterBar.filters.errorHint); }); it('should keep a custom error text across a locale change', () => { + TestBed.configureTestingModule({ + providers: [{ provide: KBQ_LOCALE_SERVICE, useClass: KbqLocaleService }] + }); + initFixture(); const component = getFiltersComponent(); - const filterBar = getFilterBar(); component.showError({ text: 'Custom error' }); - filterBar.configuration = { - ...KBQ_FILTER_BAR_DEFAULT_CONFIGURATION, - filters: { ...KBQ_FILTER_BAR_DEFAULT_CONFIGURATION.filters, errorHint: 'Locale B error hint' } - }; + TestBed.inject(KBQ_LOCALE_SERVICE).setLocale('en-US'); expect(component.filterSavingErrorText).toBe('Custom error'); }); diff --git a/packages/components/input/input-number.ts b/packages/components/input/input-number.ts index 0093177078..37afa0c218 100644 --- a/packages/components/input/input-number.ts +++ b/packages/components/input/input-number.ts @@ -2,17 +2,20 @@ import { booleanAttribute, Directive, + effect, ElementRef, EventEmitter, forwardRef, HostAttributeToken, inject, + InjectionToken, Input, input, OnDestroy, - Renderer2 + Provider, + Renderer2, + untracked } from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { AbstractControl, ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; import { BACKSPACE, @@ -33,6 +36,10 @@ import { isSelectAll, KBQ_DEFAULT_PRECISION_SEPARATOR, KBQ_LOCALE_SERVICE, + KbqDeepPartial, + kbqInjectLocaleConfiguration, + KbqInputLocaleConfiguration, + kbqLocaleConfigurationOverrideProvider, KbqLocaleService, KbqNumberInputLocaleConfig, LEFT_ARROW, @@ -51,6 +58,26 @@ import { Subject } from 'rxjs'; export const KBQ_INPUT_NUMBER_DEFAULT_CONFIGURATION = ruRUFormattersData.input.number; +/** + * Default configuration of `KbqNumberInput`: the whole `input` locale section, of which the number input + * reads `number`. + */ +export const KBQ_NUMBER_INPUT_DEFAULT_CONFIGURATION: KbqInputLocaleConfiguration = ruRUFormattersData.input; + +/** Injection token for providing the default configuration of `KbqNumberInput`. */ +export const KBQ_NUMBER_INPUT_CONFIGURATION = new InjectionToken( + 'KbqNumberInputConfiguration', + { factory: () => KBQ_NUMBER_INPUT_DEFAULT_CONFIGURATION } +); + +/** + * Utility provider for `KBQ_NUMBER_INPUT_CONFIGURATION`. Only the values you pass are overridden; the rest + * keep following the active locale. + */ +export const kbqNumberInputLocaleConfigurationProvider = ( + configuration: KbqDeepPartial +): Provider => kbqLocaleConfigurationOverrideProvider('input', configuration); + export const BIG_STEP = 10; export const SMALL_STEP = 1; @@ -232,7 +259,11 @@ export class KbqNumberInput implements KbqFormFieldControl, ControlValueAcc private control: AbstractControl; - private config: KbqNumberInputLocaleConfig; + private get config() { + return this._configuration().number; + } + + private readonly _configuration = kbqInjectLocaleConfiguration('input', KBQ_NUMBER_INPUT_CONFIGURATION); private valueFromPaste: number | null; @@ -241,7 +272,6 @@ export class KbqNumberInput implements KbqFormFieldControl, ControlValueAcc const bigStep = inject(new HostAttributeToken('big-step'), { optional: true })!; const min = inject(new HostAttributeToken('min'), { optional: true })!; const max = inject(new HostAttributeToken('max'), { optional: true })!; - const localeService = this.localeService; this.step = isDigit(step) ? parseFloat(step) : SMALL_STEP; this.bigStep = isDigit(bigStep) ? parseFloat(bigStep) : BIG_STEP; @@ -258,11 +288,14 @@ export class KbqNumberInput implements KbqFormFieldControl, ControlValueAcc }); } - this.localeService?.changes.pipe(takeUntilDestroyed()).subscribe(this.updateLocaleParams); + // Re-render the value in the separators of the new locale. `untracked` keeps the configuration the + // only dependency: formatting also reads the `withThousandSeparator` input, which must not rewrite + // what the user is typing on its own. + effect(() => { + this._configuration(); - if (!localeService) { - this.initDefaultParams(); - } + untracked(() => this.setViewValue(this.formatNumber(this.value))); + }); } ngOnDestroy(): void { @@ -450,10 +483,6 @@ export class KbqNumberInput implements KbqFormFieldControl, ControlValueAcc this.valueChange.emit(res); } - private initDefaultParams() { - this.config = KBQ_INPUT_NUMBER_DEFAULT_CONFIGURATION; - } - private isCtrlV = (event: KeyboardEvent) => { return event.keyCode === V && (event.ctrlKey || event.metaKey); }; @@ -556,10 +585,4 @@ export class KbqNumberInput implements KbqFormFieldControl, ControlValueAcc return `${formattedIntPart}${this.fractionSeparator}${formattedFractionPart}`; } - - private updateLocaleParams = () => { - this.config = this.localeService!.getParams('input').number; - - this.setViewValue(this.formatNumber(this.value)); - }; } diff --git a/packages/components/navbar/navbar-toggle.component.ts b/packages/components/navbar/navbar-toggle.component.ts index ea32701d49..767d0aadcb 100644 --- a/packages/components/navbar/navbar-toggle.component.ts +++ b/packages/components/navbar/navbar-toggle.component.ts @@ -4,6 +4,7 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, + effect, inject, NgZone, OnDestroy, @@ -64,6 +65,11 @@ export class KbqNavbarToggle implements OnDestroy { this.tooltip.tooltipPlacement = PopUpPlacements.Right; this.tooltip.visibleChange.pipe(takeUntilDestroyed()).subscribe(this.updateTooltipContent); + + // `content` is a plain property, so a tooltip that is already open keeps the string it was given. + // Reading the navbar's signal-backed configuration here re-applies it on a locale change instead + // of leaving the previous locale on screen until the next show. + effect(() => this.updateTooltipContent()); } ngOnDestroy(): void { diff --git a/packages/components/navbar/navbar.component.spec.ts b/packages/components/navbar/navbar.component.spec.ts index 9259a887ce..0b7c209227 100644 --- a/packages/components/navbar/navbar.component.spec.ts +++ b/packages/components/navbar/navbar.component.spec.ts @@ -5,7 +5,17 @@ import { Component } from '@angular/core'; import { fakeAsync, flush, TestBed, tick } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; -import { dispatchKeyboardEvent, LEFT_ARROW, RIGHT_ARROW, TAB } from '@koobiq/components/core'; +import { + dispatchKeyboardEvent, + enUSLocaleData, + KBQ_LOCALE_SERVICE, + KbqLocaleService, + LEFT_ARROW, + RIGHT_ARROW, + ruRULocaleData, + TAB +} from '@koobiq/components/core'; +import { KbqTooltipTrigger } from '@koobiq/components/tooltip'; import { Observable, Subject } from 'rxjs'; import { KbqIconModule } from './../icon/icon.module'; import { @@ -17,7 +27,9 @@ import { KbqNavbarModule, KbqNavbarRectangleElement, KbqNavbarTitle, - KbqVerticalNavbar + KbqNavbarToggle, + KbqVerticalNavbar, + kbqVerticalNavbarLocaleConfigurationProvider } from './index'; const FONT_RENDER_TIMEOUT_MS = 10; @@ -73,7 +85,8 @@ describe('KbqNavbar', () => { TestVerticalApp, TestBrandApp, TestBrandLongTitleApp, - TestBrandHorizontalApp + TestBrandHorizontalApp, + TestToggleApp ] }).compileComponents(); }); @@ -577,6 +590,64 @@ describe('KbqNavbar', () => { })); }); + describe('KbqNavbarToggle', () => { + it('tooltip content should follow a runtime locale change', () => { + TestBed.configureTestingModule({ + providers: [{ provide: KBQ_LOCALE_SERVICE, useClass: KbqLocaleService }] + }); + + const fixture = TestBed.createComponent(TestToggleApp); + + fixture.detectChanges(); + + const tooltip = fixture.debugElement.query(By.directive(KbqNavbarToggle)).injector.get(KbqTooltipTrigger); + + expect(tooltip.content).toBe(ruRULocaleData.navbar.toggle.expand); + + // The tooltip is deliberately never shown nor hidden here: `visibleChange` would refresh the + // content on its own and hide a toggle that never reacts to the locale itself. + TestBed.inject(KBQ_LOCALE_SERVICE).setLocale('en-US'); + fixture.detectChanges(); + + expect(tooltip.content).toBe(enUSLocaleData.navbar.toggle.expand); + }); + + it('tooltip content should follow an override registered through the provider', () => { + const expand = '*unit_test* Open the menu'; + + TestBed.configureTestingModule({ + providers: [ + { provide: KBQ_LOCALE_SERVICE, useClass: KbqLocaleService }, + kbqVerticalNavbarLocaleConfigurationProvider({ toggle: { expand } }) + ] + }); + + const fixture = TestBed.createComponent(TestToggleApp); + + fixture.detectChanges(); + + const tooltip = fixture.debugElement.query(By.directive(KbqNavbarToggle)).injector.get(KbqTooltipTrigger); + const navbar = fixture.debugElement.query(By.directive(KbqVerticalNavbar)).injector.get(KbqVerticalNavbar); + + expect(tooltip.content).toBe(expand); + + TestBed.inject(KBQ_LOCALE_SERVICE).setLocale('en-US'); + fixture.detectChanges(); + + // An override outranks the locale — that is what distinguishes it from a default. + expect(tooltip.content).toBe(expand); + + // The strings the override does not mention keep following the locale. Expanding first swaps the + // tooltip onto one of them: asserting only the pinned string above would pass just as well + // against a tooltip frozen at construction. + navbar.expanded = true; + TestBed.inject(KBQ_LOCALE_SERVICE).setLocale('ru-RU'); + fixture.detectChanges(); + + expect(tooltip.content).toBe(ruRULocaleData.navbar.toggle.collapse); + }); + }); + describe('KbqNavbarRectangleElement', () => { it('setting horizontal=true should add kbq-horizontal and remove kbq-vertical', fakeAsync(() => { const fixture = TestBed.createComponent(TestItemApp); @@ -1037,3 +1108,14 @@ class TestBrandHorizontalApp {} ` }) class TestVerticalApp {} + +@Component({ + selector: 'test-toggle-app', + imports: [KbqNavbarModule], + template: ` + + + + ` +}) +class TestToggleApp {} diff --git a/packages/components/navbar/vertical-navbar.component.ts b/packages/components/navbar/vertical-navbar.component.ts index 97b93656d0..6ff5ac613e 100644 --- a/packages/components/navbar/vertical-navbar.component.ts +++ b/packages/components/navbar/vertical-navbar.component.ts @@ -13,6 +13,7 @@ import { InjectionToken, Input, input, + Provider, ViewEncapsulation } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @@ -20,7 +21,10 @@ import { DOWN_ARROW, isHorizontalMovement, isVerticalMovement, - KBQ_LOCALE_SERVICE, + KbqDeepPartial, + kbqInjectLocaleConfiguration, + kbqLocaleConfigurationOverrideProvider, + KbqNavbarLocaleConfiguration, ruRULocaleData, TAB, UP_ARROW @@ -35,7 +39,18 @@ export const KBQ_VERTICAL_NAVBAR_DEFAULT_CONFIGURATION = ruRULocaleData.navbar; /** Injection Token for providing configuration of navbar */ /** @docs-private */ -export const KBQ_VERTICAL_NAVBAR_CONFIGURATION = new InjectionToken('KbqVerticalNavbarConfiguration'); +export const KBQ_VERTICAL_NAVBAR_CONFIGURATION = new InjectionToken( + 'KbqVerticalNavbarConfiguration', + { factory: () => KBQ_VERTICAL_NAVBAR_DEFAULT_CONFIGURATION } +); + +/** + * Utility provider for `KBQ_VERTICAL_NAVBAR_CONFIGURATION`. Only the strings you pass are overridden; the + * rest keep following the active locale. + */ +export const kbqVerticalNavbarLocaleConfigurationProvider = ( + configuration: KbqDeepPartial +): Provider => kbqLocaleConfigurationOverrideProvider('navbar', configuration); @Component({ selector: 'kbq-vertical-navbar', @@ -71,10 +86,18 @@ export const KBQ_VERTICAL_NAVBAR_CONFIGURATION = new InjectionToken('KbqVertical export class KbqVerticalNavbar extends KbqFocusableComponent implements AfterContentInit { protected elementRef: ElementRef; - /** @docs-private */ - protected readonly localeService = inject(KBQ_LOCALE_SERVICE, { optional: true }); - readonly externalConfiguration = inject(KBQ_VERTICAL_NAVBAR_CONFIGURATION, { optional: true }); - configuration; + /** + * Localized strings of the collapse toggle. + * + * A getter over the signal the helper returns, so that a runtime `setLocale()` stays observable from + * outside this component: `KbqNavbarToggle` reads it in an `effect` to refresh its tooltip, which a + * `markForCheck()` here could never have reached in that separate `OnPush` view. + */ + get configuration(): KbqNavbarLocaleConfiguration { + return this._configuration(); + } + + private readonly _configuration = kbqInjectLocaleConfiguration('navbar', KBQ_VERTICAL_NAVBAR_CONFIGURATION); rectangleElements = contentChildren( forwardRef(() => KbqNavbarRectangleElement), @@ -116,12 +139,6 @@ export class KbqVerticalNavbar extends KbqFocusableComponent implements AfterCon this.animationDone.pipe(takeUntilDestroyed()).subscribe(this.updateTooltipForItems); effect(() => this.setItemsVerticalStateAndUpdateExpandedState(this.rectangleElements())); - - this.localeService?.changes.pipe(takeUntilDestroyed()).subscribe(this.updateLocaleParams); - - if (!this.localeService) { - this.initDefaultParams(); - } } ngAfterContentInit(): void { @@ -181,14 +198,4 @@ export class KbqVerticalNavbar extends KbqFocusableComponent implements AfterCon item.collapsed = !this.expanded; setTimeout(() => item.button()?.updateClassModifierForIcons()); }; - - private updateLocaleParams = () => { - this.configuration = this.externalConfiguration || this.localeService?.getParams('navbar'); - - this.changeDetectorRef.markForCheck(); - }; - - private initDefaultParams() { - this.configuration = KBQ_VERTICAL_NAVBAR_DEFAULT_CONFIGURATION; - } } diff --git a/packages/components/notification-center/notification-center.spec.ts b/packages/components/notification-center/notification-center.spec.ts index 8f50d620bd..3b6aaa1510 100644 --- a/packages/components/notification-center/notification-center.spec.ts +++ b/packages/components/notification-center/notification-center.spec.ts @@ -4,7 +4,14 @@ import { ComponentFixture, TestBed, fakeAsync, inject, tick } from '@angular/cor import { By } from '@angular/platform-browser'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { KbqLuxonDateModule } from '@koobiq/angular-luxon-adapter/adapter'; -import { KbqFormattersModule, dispatchFakeEvent } from '@koobiq/components/core'; +import { + KBQ_LOCALE_SERVICE, + KbqFormattersModule, + KbqLocaleService, + dispatchFakeEvent, + enUSLocaleData, + ruRULocaleData +} from '@koobiq/components/core'; import { KbqNotificationCenterModule, KbqNotificationCenterService, @@ -746,6 +753,53 @@ describe('KbqNotificationCenter', () => { expect(pane.style.left).toBe('50px'); })); }); + + describe('locale', () => { + let localeFixture: ComponentFixture; + let localeService: KbqLocaleService; + + beforeEach(() => { + testScheduler = new TestScheduler((act, exp) => expect(exp).toEqual(act)); + localeFixture = createComponent(KbqNotificationCenterSimple, [ + { provide: KBQ_LOCALE_SERVICE, useClass: KbqLocaleService } + ]); + + overlayContainer = TestBed.inject(OverlayContainer); + // The component resolves the service from the token, so the test must drive that very instance. + localeService = TestBed.inject(KBQ_LOCALE_SERVICE); + }); + + afterEach(() => { + overlayContainer?.ngOnDestroy(); + }); + + // Rendered by KbqNotificationItemComponent, not by the center itself. + const getItemRemoveButtonLabel = () => + overlayContainer + .getContainerElement() + .querySelector('[data-testid="kbq-notification-item-remove-button"]') + ?.getAttribute('aria-label'); + + it('relabels the remove button of already rendered items when the locale changes at runtime', () => { + const trigger = localeFixture.componentInstance.trigger(); + const service = (trigger as unknown as { service: KbqNotificationCenterService }).service; + const item: KbqNotificationItem = { title: 'a', date: new Date().toISOString() }; + + service.items = [item]; + + trigger.show(); + localeFixture.detectChanges(); + + expect(getItemRemoveButtonLabel()).toBe(ruRULocaleData.notificationCenter.remove); + + localeService.setLocale('en-US'); + localeFixture.detectChanges(); + + // The item is a separate OnPush component reading the center's locale data from its own + // template: marking the center for check leaves the already rendered item untouched. + expect(getItemRemoveButtonLabel()).toBe(enUSLocaleData.notificationCenter.remove); + }); + }); }); @Component({ diff --git a/packages/components/notification-center/notification-center.ts b/packages/components/notification-center/notification-center.ts index ce2de42ad0..607b8b63bf 100644 --- a/packages/components/notification-center/notification-center.ts +++ b/packages/components/notification-center/notification-center.ts @@ -12,6 +12,7 @@ import { InjectionToken, Input, Output, + Provider, TemplateRef, Type, ViewEncapsulation, @@ -25,7 +26,8 @@ import { KbqBadgeModule } from '@koobiq/components/badge'; import { KbqButton, KbqButtonModule } from '@koobiq/components/button'; import { DateAdapter, - KBQ_LOCALE_SERVICE, + KbqDeepPartial, + KbqNotificationCenterLocaleConfiguration, KbqOverflowShadowBottom, KbqOverflowShadowContainer, KbqOverflowShadowTop, @@ -40,6 +42,8 @@ import { PopUpTriggers, applyPopupMargins, kbqInjectA11yLocaleConfiguration, + kbqInjectLocaleConfiguration, + kbqLocaleConfigurationOverrideProvider, ruRULocaleData } from '@koobiq/components/core'; import { KbqDividerModule } from '@koobiq/components/divider'; @@ -79,7 +83,18 @@ const SCROLLED_TO_BOTTOM_TOLERANCE = 2; export const KBQ_NOTIFICATION_CENTER_DEFAULT_CONFIGURATION = ruRULocaleData.notificationCenter; /** Injection Token for providing configuration of notification-center */ -export const KBQ_NOTIFICATION_CENTER_CONFIGURATION = new InjectionToken('KbqNotificationCenterConfiguration'); +export const KBQ_NOTIFICATION_CENTER_CONFIGURATION = new InjectionToken( + 'KbqNotificationCenterConfiguration', + { factory: () => KBQ_NOTIFICATION_CENTER_DEFAULT_CONFIGURATION } +); + +/** + * Utility provider for `KBQ_NOTIFICATION_CENTER_CONFIGURATION`. Only the strings you pass are overridden; the + * rest keep following the active locale. + */ +export const kbqNotificationCenterLocaleConfigurationProvider = ( + configuration: KbqDeepPartial +): Provider => kbqLocaleConfigurationOverrideProvider('notificationCenter', configuration); /** @docs-private */ export const KBQ_NOTIFICATION_CENTER_SCROLL_STRATEGY = new InjectionToken<() => ScrollStrategy>( @@ -137,19 +152,29 @@ export class KbqNotificationCenterComponent extends KbqPopUp implements AfterVie /** @docs-private */ protected readonly changeDetectorRef = inject(ChangeDetectorRef); /** @docs-private */ - protected readonly localeService = inject(KBQ_LOCALE_SERVICE, { optional: true }); - /** @docs-private */ protected readonly dateAdapter = inject(DateAdapter); /** @docs-private */ protected readonly service = inject(KbqNotificationCenterService); - readonly externalConfiguration = inject(KBQ_NOTIFICATION_CENTER_CONFIGURATION, { optional: true }); - /** Accessible names for the icon-only toolbar buttons. * @docs-private */ protected readonly a11yLocaleConfiguration = kbqInjectA11yLocaleConfiguration(); - configuration; + /** + * Localized strings of the notification center. + * + * Read through a signal so that a runtime `setLocale()` reaches `KbqNotificationItemComponent`, which + * renders these strings from its own `OnPush` view: a `markForCheck()` here would mark this component + * only, never the already-rendered items. + */ + get configuration(): KbqNotificationCenterLocaleConfiguration { + return this._configuration(); + } + + private readonly _configuration = kbqInjectLocaleConfiguration( + 'notificationCenter', + KBQ_NOTIFICATION_CENTER_CONFIGURATION + ); /** @docs-private */ protected popoverMode: boolean; @@ -193,12 +218,6 @@ export class KbqNotificationCenterComponent extends KbqPopUp implements AfterVie constructor() { super(); - - this.localeService?.changes.pipe(takeUntilDestroyed()).subscribe(this.updateLocaleParams); - - if (!this.localeService) { - this.initDefaultParams(); - } } ngAfterViewInit() { @@ -342,16 +361,6 @@ export class KbqNotificationCenterComponent extends KbqPopUp implements AfterVie escapeHandler() { this.hide(0); } - - private updateLocaleParams = () => { - this.configuration = this.externalConfiguration || this.localeService?.getParams('notificationCenter'); - - this.changeDetectorRef.markForCheck(); - }; - - private initDefaultParams() { - this.configuration = KBQ_NOTIFICATION_CENTER_DEFAULT_CONFIGURATION; - } } @Directive({ diff --git a/packages/components/search-expandable/search-expandable.ts b/packages/components/search-expandable/search-expandable.ts index db3893fa25..d607e01803 100644 --- a/packages/components/search-expandable/search-expandable.ts +++ b/packages/components/search-expandable/search-expandable.ts @@ -14,6 +14,7 @@ import { numberAttribute, OnDestroy, output, + Provider, QueryList, viewChild, ViewChildren, @@ -22,7 +23,13 @@ import { import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { ControlValueAccessor, FormsModule, NgControl, ReactiveFormsModule } from '@angular/forms'; import { KbqButton, KbqButtonModule } from '@koobiq/components/button'; -import { KBQ_LOCALE_SERVICE, ruRULocaleData } from '@koobiq/components/core'; +import { + KbqDeepPartial, + kbqInjectLocaleConfiguration, + kbqLocaleConfigurationOverrideProvider, + KbqSearchExpandableLocaleConfiguration, + ruRULocaleData +} from '@koobiq/components/core'; import { KbqIconModule } from '@koobiq/components/icon'; import { KbqInput, KbqInputModule } from '@koobiq/components/input'; import { KbqToolTipModule, KbqTooltipTrigger } from '@koobiq/components/tooltip'; @@ -33,7 +40,18 @@ import { map } from 'rxjs/operators'; export const KBQ_SEARCH_EXPANDABLE_DEFAULT_CONFIGURATION = ruRULocaleData.searchExpandable; /** Injection Token for providing configuration of search-expandable */ -export const KBQ_SEARCH_EXPANDABLE_CONFIGURATION = new InjectionToken('KbqSearchExpandableConfiguration'); +export const KBQ_SEARCH_EXPANDABLE_CONFIGURATION = new InjectionToken( + 'KbqSearchExpandableConfiguration', + { factory: () => KBQ_SEARCH_EXPANDABLE_DEFAULT_CONFIGURATION } +); + +/** + * Utility provider for `KBQ_SEARCH_EXPANDABLE_CONFIGURATION`. Only the strings you pass are overridden; the + * rest keep following the active locale. + */ +export const kbqSearchExpandableLocaleConfigurationProvider = ( + configuration: KbqDeepPartial +): Provider => kbqLocaleConfigurationOverrideProvider('searchExpandable', configuration); export const defaultValue = ''; export const defaultEmitValueTimeout = 200; @@ -63,21 +81,25 @@ export class KbqSearchExpandable implements ControlValueAccessor, AfterViewInit, /** @docs-private */ protected readonly focusMonitor = inject(FocusMonitor); /** @docs-private */ - protected readonly localeService = inject(KBQ_LOCALE_SERVICE, { optional: true }); - /** @docs-private */ protected readonly destroyRef = inject(DestroyRef); /** @docs-private */ protected readonly changeDetectorRef = inject(ChangeDetectorRef); /** @docs-private */ protected readonly nativeElement: HTMLElement = inject(ElementRef).nativeElement; - readonly externalConfiguration = inject(KBQ_SEARCH_EXPANDABLE_CONFIGURATION, { optional: true }); - @ViewChildren(KbqInput) private input: QueryList; @ViewChildren(KbqButton) private button: QueryList; private readonly tooltip = viewChild(KbqTooltipTrigger); - configuration; + /** Strings currently rendered by the component. */ + get configuration(): KbqSearchExpandableLocaleConfiguration { + return this._configuration(); + } + + private readonly _configuration = kbqInjectLocaleConfiguration( + 'searchExpandable', + KBQ_SEARCH_EXPANDABLE_CONFIGURATION + ); /** Current value in input. */ value = new BehaviorSubject(defaultValue); @@ -122,7 +144,7 @@ export class KbqSearchExpandable implements ControlValueAccessor, AfterViewInit, this._placeholder = value; } - private _placeholder: string | null = this.localeData?.placeholder; + private _placeholder: string | null; // TODO: Skipped for migration because: // Accessor inputs cannot be migrated as they are too complex. @@ -176,12 +198,6 @@ export class KbqSearchExpandable implements ControlValueAccessor, AfterViewInit, this.ngControl.valueChanges?.pipe(takeUntilDestroyed()).subscribe((value) => this.value.next(value)); - this.localeService?.changes.pipe(takeUntilDestroyed()).subscribe(this.updateLocaleParams); - - if (!this.localeService) { - this.initDefaultParams(); - } - this.value .pipe( distinctUntilChanged(), @@ -306,16 +322,6 @@ export class KbqSearchExpandable implements ControlValueAccessor, AfterViewInit, this.changeDetectorRef.markForCheck(); } - private updateLocaleParams = () => { - this.configuration = this.externalConfiguration || this.localeService?.getParams('searchExpandable'); - - this.changeDetectorRef.markForCheck(); - }; - - private initDefaultParams() { - this.configuration = KBQ_SEARCH_EXPANDABLE_DEFAULT_CONFIGURATION; - } - private emitValue = (value: string, forced = false): void => { if (value !== this.lastEmittedValue || forced) { this.onChange(value); diff --git a/packages/components/select/select.component.ts b/packages/components/select/select.component.ts index 0280e69d8c..76494f3669 100644 --- a/packages/components/select/select.component.ts +++ b/packages/components/select/select.component.ts @@ -57,15 +57,14 @@ import { HOME, KBQ_CONNECTED_OVERLAY_ABOVE_CLASS, KBQ_CONNECTED_OVERLAY_BELOW_CLASS, - KBQ_LOCALE_SERVICE, KBQ_OPTION_PARENT_COMPONENT, KBQ_PANEL_DEFAULT_MIN_WIDTH, KBQ_PARENT_POPUP, + KBQ_SELECT_LOCALE_CONFIGURATION, KBQ_SELECT_SCROLL_STRATEGY, KBQ_WINDOW, KbqAbstractSelect, KbqComponentColors, - KbqLocaleService, KbqOptgroup, KbqOption, KbqOptionBase, @@ -100,10 +99,10 @@ import { isInput, isSelectAll, isUndefined, + kbqInjectLocaleConfiguration, kbqResolvePanelMaxHeightToken, kbqSelectAnimations, kbqSiblingPopupProvider, - ruRULocaleData, shouldSelectSearchText, toggleSelectAll } from '@koobiq/components/core'; @@ -277,7 +276,10 @@ export class KbqSelect private readonly parentFormField = inject(KBQ_FORM_FIELD, { host: true, optional: true })!; ngControl = inject(NgControl, { self: true, optional: true }); private readonly scrollStrategyFactory = inject(KBQ_SELECT_SCROLL_STRATEGY); - protected localeService? = inject(KBQ_LOCALE_SERVICE, { optional: true }); + + /** Localized strings of the select, following the active locale. */ + private readonly localeConfiguration = kbqInjectLocaleConfiguration('select', KBQ_SELECT_LOCALE_CONFIGURATION); + /** @docs-private */ protected readonly destroyRef = inject(DestroyRef); @@ -438,13 +440,28 @@ export class KbqSelect /** Reference to the optional empty search result component. */ readonly searchEmpty = contentChild(KbqSelectSearchEmptyResult); - /** Template string for hidden items text. Supports {{ number }} placeholder. */ + /** + * Template string for hidden items text. Supports {{ number }} placeholder. + * + * Follows the active locale while unset; a value assigned here takes precedence over every locale. + */ // TODO: Skipped for migration because: - // Your application code writes to the input. This prevents migration. - @Input() hiddenItemsText: string = '+{{ number }}'; + // Accessor inputs cannot be migrated as they are too complex. + @Input() + get hiddenItemsText(): string { + return this._hiddenItemsText ?? this.localeConfiguration().hiddenItemsText; + } + + set hiddenItemsText(value: string) { + this._hiddenItemsText = value; + } + + private _hiddenItemsText?: string; - /** Label of the "select all" row. Kept in step with the locale service. */ - protected selectAllText: string = ruRULocaleData.select.selectAll; + /** Label of the "select all" row. Follows the active locale. */ + protected get selectAllText(): string { + return this.localeConfiguration().selectAll; + } /** Determines whether preselected values are displayed. */ readonly showPreselectedValues = input(false); @@ -1064,8 +1081,6 @@ export class KbqSelect constructor() { super(); - this.localeService?.changes.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(this.updateLocaleParams); - // The "select all" row only exists while the panel is attached, so the key manager's list has to // be rebuilt whenever the view query resolves or drops it — `options.changes` alone never fires // for it. @@ -1665,18 +1680,6 @@ export class KbqSelect ); } - /** Updates locale parameters from the locale service. */ - private updateLocaleParams = () => { - // A consumer-supplied locale (`KBQ_LOCALE_DATA`/`addLocale`) may predate the `select` section - // entirely, not just the `selectAll` key within it — guard the lookup itself, not just its fields. - const params = this.localeService?.getParams('select'); - - this.hiddenItemsText = params?.hiddenItemsText ?? this.hiddenItemsText; - this.selectAllText = params?.selectAll ?? ruRULocaleData.select.selectAll; - - this._changeDetectorRef.markForCheck(); - }; - /** Checks if the component is currently visible in the viewport. */ private isVisible(): boolean { if (!this.isBrowser) return false; diff --git a/packages/components/time-range/time-range.spec.ts b/packages/components/time-range/time-range.spec.ts index 2143837d40..a2b1bb3ed9 100644 --- a/packages/components/time-range/time-range.spec.ts +++ b/packages/components/time-range/time-range.spec.ts @@ -5,14 +5,26 @@ import { FormControl, ReactiveFormsModule } from '@angular/forms'; import { By } from '@angular/platform-browser'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { KbqLuxonDateModule, LuxonDateModule } from '@koobiq/angular-luxon-adapter/adapter'; -import { DateFormatter, KbqFormattersModule } from '@koobiq/components/core'; +import { + DateFormatter, + enUSLocaleData, + KBQ_LOCALE_SERVICE, + KbqFormattersModule, + kbqInjectLocaleConfiguration, + KbqLocaleService, + ruRULocaleData +} from '@koobiq/components/core'; import { KbqFormFieldModule } from '@koobiq/components/form-field'; import { KbqIconModule } from '@koobiq/components/icon'; import { KbqPopoverComponent } from '@koobiq/components/popover'; import { KbqRadioButton } from '@koobiq/components/radio'; import { KBQ_CUSTOM_TIME_RANGE_TYPES, KBQ_DEFAULT_TIME_RANGE_TYPES } from './constants'; import { KbqTimeRangeModule } from './module'; -import { KbqTimeRange } from './time-range'; +import { + KBQ_TIME_RANGE_LOCALE_CONFIGURATION, + KbqTimeRange, + kbqTimeRangeLocaleConfigurationProvider +} from './time-range'; import { KbqTimeRangeTitle } from './time-range-title'; import { KbqCustomTimeRangeType, KbqTimeRangeRange, KbqTimeRangeType } from './types'; @@ -162,6 +174,48 @@ describe('KbqTimeRange', () => { ).toMatchSnapshot(); })); }); + + describe('kbqTimeRangeLocaleConfigurationProvider', () => { + const apply = '*unit_test* Apply'; + + const injectConfiguration = (providers: unknown[]) => { + TestBed.configureTestingModule({ providers: providers as [] }); + + return TestBed.runInInjectionContext(() => + kbqInjectLocaleConfiguration('timeRange', KBQ_TIME_RANGE_LOCALE_CONFIGURATION) + ); + }; + + it('should override a nested key while keeping the rest at the defaults', () => { + const { timeRange } = ruRULocaleData; + + const { editor, title } = injectConfiguration([ + kbqTimeRangeLocaleConfigurationProvider({ editor: { apply } }) + ])(); + + expect(editor.apply).toBe(apply); + // The siblings of the overridden key are what a shallow merge of the section would drop. + expect(editor.cancel).toBe(timeRange.editor.cancel); + expect(editor.from).toBe(timeRange.editor.from); + expect(editor.to).toBe(timeRange.editor.to); + expect(title).toBe(timeRange.title); + }); + + it('should apply the override on top of the active locale', () => { + const configuration = injectConfiguration([ + { provide: KBQ_LOCALE_SERVICE, useClass: KbqLocaleService }, + kbqTimeRangeLocaleConfigurationProvider({ editor: { apply } }) + ]); + + expect(configuration().editor.apply).toBe(apply); + + TestBed.inject(KBQ_LOCALE_SERVICE).setLocale('en-US'); + + // The overridden key stays pinned, everything else follows the locale. + expect(configuration().editor.apply).toBe(apply); + expect(configuration().editor.cancel).toBe(enUSLocaleData.timeRange.editor.cancel); + }); + }); }); @Component({ diff --git a/packages/components/time-range/time-range.ts b/packages/components/time-range/time-range.ts index 46425adc89..4ca79ad7ef 100644 --- a/packages/components/time-range/time-range.ts +++ b/packages/components/time-range/time-range.ts @@ -18,8 +18,10 @@ import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; import { ControlValueAccessor, FormControl, NgControl, ReactiveFormsModule } from '@angular/forms'; import { KbqButtonModule } from '@koobiq/components/button'; import { - KBQ_LOCALE_SERVICE, - KbqTimeRangeLocaleConfig, + KbqDeepPartial, + kbqInjectLocaleConfiguration, + kbqLocaleConfigurationOverrideProvider, + KbqTimeRangeLocaleConfiguration, PopUpPlacements, PopUpSizes, ruRULocaleData @@ -37,16 +39,18 @@ import { } from './types'; /** Localization configuration provider. */ -export const KBQ_TIME_RANGE_LOCALE_CONFIGURATION = new InjectionToken( +export const KBQ_TIME_RANGE_LOCALE_CONFIGURATION = new InjectionToken( 'KBQ_TIME_RANGE_LOCALE_CONFIGURATION', { factory: () => ruRULocaleData.timeRange } ); -/** Utility provider for `KBQ_TIME_RANGE_LOCALE_CONFIGURATION`. */ -export const kbqTimeRangeLocaleConfigurationProvider = (configuration: KbqTimeRangeLocaleConfig): Provider => ({ - provide: KBQ_TIME_RANGE_LOCALE_CONFIGURATION, - useValue: configuration -}); +/** + * Utility provider for `KBQ_TIME_RANGE_LOCALE_CONFIGURATION`. Only the strings you pass are overridden; + * the rest keep following the active locale. + */ +export const kbqTimeRangeLocaleConfigurationProvider = ( + configuration: KbqDeepPartial +): Provider => kbqLocaleConfigurationOverrideProvider('timeRange', configuration); @Component({ selector: 'kbq-time-range', @@ -113,7 +117,6 @@ export const kbqTimeRangeLocaleConfigurationProvider = (configuration: KbqTimeRa }) export class KbqTimeRange implements ControlValueAccessor, OnInit { private readonly timeRangeService = inject>(KbqTimeRangeService); - private readonly localeService = inject(KBQ_LOCALE_SERVICE, { optional: true }); /** @docs-private */ readonly ngControl = inject(NgControl, { optional: true, self: true }); @@ -161,7 +164,10 @@ export class KbqTimeRange implements ControlValueAccessor, OnInit { protected readonly popupPlacement = PopUpPlacements.BottomLeft; /** @docs-private */ - protected readonly localeConfiguration = signal(inject(KBQ_TIME_RANGE_LOCALE_CONFIGURATION)); + protected readonly localeConfiguration = kbqInjectLocaleConfiguration( + 'timeRange', + KBQ_TIME_RANGE_LOCALE_CONFIGURATION + ); constructor() { if (this.ngControl) { @@ -176,10 +182,6 @@ export class KbqTimeRange implements ControlValueAccessor, OnInit { this.titleValue = signal(this.nonNullable() ? defaultValue : null); this.rangeEditorControl = new FormControl(defaultValue, { nonNullable: true }); - this.localeService?.changes.pipe(takeUntilDestroyed()).subscribe(() => { - this.localeConfiguration.set(this.localeService?.getParams('timeRange') ?? ruRULocaleData.timeRange); - }); - toObservable(this.availableTimeRangeTypes) .pipe(takeUntilDestroyed()) .subscribe(this.handleAvailableTypesChange); diff --git a/packages/components/timepicker/timepicker.directive.ts b/packages/components/timepicker/timepicker.directive.ts index dc970aacff..c7377b761f 100644 --- a/packages/components/timepicker/timepicker.directive.ts +++ b/packages/components/timepicker/timepicker.directive.ts @@ -3,12 +3,15 @@ import { AfterContentInit, Directive, DoCheck, + effect, ElementRef, forwardRef, inject, + InjectionToken, Input, OnDestroy, output, + Provider, Renderer2 } from '@angular/core'; import { @@ -36,13 +39,16 @@ import { isHorizontalMovement, isLetterKey, isVerticalMovement, - KBQ_LOCALE_SERVICE, + KbqDeepPartial, KbqErrorStateTracker, - KbqLocaleService, + kbqInjectLocaleConfiguration, + kbqLocaleConfigurationOverrideProvider, + KbqTimepickerLocaleConfiguration, LEFT_ARROW, PAGE_DOWN, PAGE_UP, RIGHT_ARROW, + ruRULocaleData, SPACE, TAB, UP_ARROW, @@ -51,7 +57,7 @@ import { } from '@koobiq/components/core'; import { KbqFormFieldControl } from '@koobiq/components/form-field'; import type { KbqTooltipTrigger } from '@koobiq/components/tooltip'; -import { noop, Subject, Subscription } from 'rxjs'; +import { noop, Subject } from 'rxjs'; import { AM_PM_FORMAT_REGEXP, @@ -82,6 +88,25 @@ export const KBQ_TIMEPICKER_VALIDATORS: any = { multi: true }; +/** Default configuration of the timepicker. + * @docs-private */ +export const KBQ_TIMEPICKER_DEFAULT_CONFIGURATION: KbqTimepickerLocaleConfiguration = ruRULocaleData.timepicker; + +/** Injection token for providing the default configuration of the timepicker. + * @docs-private */ +export const KBQ_TIMEPICKER_CONFIGURATION = new InjectionToken( + 'KbqTimepickerConfiguration', + { factory: () => KBQ_TIMEPICKER_DEFAULT_CONFIGURATION } +); + +/** + * Utility provider for `KBQ_TIMEPICKER_CONFIGURATION`. Only the strings you pass are overridden; the rest + * keep following the active locale. + */ +export const kbqTimepickerLocaleConfigurationProvider = ( + configuration: KbqDeepPartial +): Provider => kbqLocaleConfigurationOverrideProvider('timepicker', configuration); + let uniqueComponentIdSuffix: number = 0; const shortFormatSize: number = 5; @@ -120,7 +145,7 @@ export class KbqTimepicker private elementRef = inject>(ElementRef); private renderer = inject(Renderer2); private dateAdapter = inject>(DateAdapter, { optional: true })!; - private localeService = inject(KBQ_LOCALE_SERVICE, { optional: true }); + private readonly configuration = kbqInjectLocaleConfiguration('timepicker', KBQ_TIMEPICKER_CONFIGURATION); /** * Implemented as part of KbqFormFieldControl. * @docs-private @@ -367,7 +392,7 @@ export class KbqTimepicker /** Localized placeholder */ get timeFormatPlaceholder(): string { return ( - this.localeService?.getParams('timepicker')?.placeholder[TimeFormatToLocaleKeys[this.format]] || + this.configuration().placeholder[TimeFormatToLocaleKeys[this.format]] || TIMEFORMAT_PLACEHOLDERS[this.format] ); } @@ -395,13 +420,9 @@ export class KbqTimepicker private onChange: (value: any) => void; private onTouched: () => void; - private localeSubscription = Subscription.EMPTY; - private errorStateTracker: KbqErrorStateTracker; constructor() { - const dateAdapter = this.dateAdapter; - if (!this.dateAdapter) { throw Error( `KbqTimepicker: No provider found for DateAdapter. You must import one of the existing ` + @@ -425,7 +446,18 @@ export class KbqTimepicker this.stateChanges ); - this.localeSubscription = dateAdapter.localeChanges.subscribe(this.updateLocaleParams); + effect(() => { + // Read before the guard: an early return that skipped it would leave the effect with nothing + // to track, and the next locale change would never reach the input. + const placeholder = this.timeFormatPlaceholder; + + if (!this.defaultPlaceholder) return; + + // Assigned through the private field so that the setter does not mark it as consumer-provided. + this._placeholder = placeholder; + // Re-assigning the value re-runs it through the date adapter, which formats on the new locale. + this.value = this._value; + }); } ngDoCheck() { @@ -443,7 +475,6 @@ export class KbqTimepicker ngOnDestroy(): void { this.stateChanges.complete(); - this.localeSubscription.unsubscribe(); } getSize(): number { @@ -1018,13 +1049,4 @@ export class KbqTimepicker } private validatorOnChange = () => {}; - - private updateLocaleParams = () => { - if (!this.defaultPlaceholder) return; - - // update via private property instead of setter to save it as default placeholder - this._placeholder = this.timeFormatPlaceholder; - // update value so view value will be also updated - this.value = this._value; - }; } diff --git a/packages/components/timezone/timezone-select.component.ts b/packages/components/timezone/timezone-select.component.ts index b4e070704f..40b9686c78 100644 --- a/packages/components/timezone/timezone-select.component.ts +++ b/packages/components/timezone/timezone-select.component.ts @@ -1,16 +1,25 @@ import { CdkMonitorFocus } from '@angular/cdk/a11y'; import { CdkConnectedOverlay, CdkOverlayOrigin } from '@angular/cdk/overlay'; import { - AfterContentInit, ChangeDetectionStrategy, Component, contentChild, Directive, + effect, inject, + InjectionToken, + Provider, ViewEncapsulation } from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { KBQ_OPTION_PARENT_COMPONENT, kbqSiblingPopupProvider, ruRULocaleData } from '@koobiq/components/core'; +import { + KBQ_OPTION_PARENT_COMPONENT, + KbqDeepPartial, + kbqInjectLocaleConfiguration, + kbqLocaleConfigurationOverrideProvider, + kbqSiblingPopupProvider, + KbqTimezoneLocaleConfiguration, + ruRULocaleData +} from '@koobiq/components/core'; import { kbqCleanerFactoryProvider, KbqFormFieldControl } from '@koobiq/components/form-field'; import { KbqIconModule } from '@koobiq/components/icon'; import { KbqSelect } from '@koobiq/components/select'; @@ -20,7 +29,24 @@ import { KbqSelect } from '@koobiq/components/select'; }) export class KbqTimezoneSelectTrigger {} -const defaultSearchPlaceholder = ruRULocaleData.timezone.searchPlaceholder; +/** default configuration of timezone + * @docs-private */ +export const KBQ_TIMEZONE_DEFAULT_CONFIGURATION: KbqTimezoneLocaleConfiguration = ruRULocaleData.timezone; + +/** Injection Token for providing the default configuration of timezone + * @docs-private */ +export const KBQ_TIMEZONE_CONFIGURATION = new InjectionToken( + 'KbqTimezoneConfiguration', + { factory: () => KBQ_TIMEZONE_DEFAULT_CONFIGURATION } +); + +/** + * Utility provider for `KBQ_TIMEZONE_CONFIGURATION`. Only the strings you pass are overridden; the rest keep + * following the active locale. + */ +export const kbqTimezoneLocaleConfigurationProvider = ( + configuration: KbqDeepPartial +): Provider => kbqLocaleConfigurationOverrideProvider('timezone', configuration); @Component({ selector: 'kbq-timezone-select', @@ -62,26 +88,31 @@ const defaultSearchPlaceholder = ruRULocaleData.timezone.searchPlaceholder; encapsulation: ViewEncapsulation.None, exportAs: 'kbqTimezoneSelect' }) -export class KbqTimezoneSelect extends KbqSelect implements AfterContentInit { +export class KbqTimezoneSelect extends KbqSelect { readonly customTrigger = contentChild(KbqTimezoneSelectTrigger); - ngAfterContentInit() { - super.ngAfterContentInit(); - - this.localeService?.changes - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(this.updateLocaleParamsForSearch); - - this.updateLocaleParamsForSearch(); + /** Strings currently rendered by the select. */ + get configuration(): KbqTimezoneLocaleConfiguration { + return this._configuration(); } - private updateLocaleParamsForSearch = () => { - const placeholder = this.localeService?.getParams('timezone').searchPlaceholder || defaultSearchPlaceholder; + private readonly _configuration = kbqInjectLocaleConfiguration('timezone', KBQ_TIMEZONE_CONFIGURATION); + + constructor() { + super(); - const search = this.search(); + // The projected search takes its placeholder as a plain property rather than through a template + // binding, so the string has to be pushed into it. An effect applies it as soon as the query + // resolves, without waiting for a lifecycle hook of this component. + effect(() => { + const placeholder = this._configuration().searchPlaceholder; + const search = this.search(); - if (search && !search.hasPlaceholder()) { - search.setPlaceholder(placeholder); - } - }; + // A placeholder supplied by the consumer wins and is never overwritten - which also means the + // locale one is applied only once, exactly as the previous subscription did. + if (search && !search.hasPlaceholder()) { + search.setPlaceholder(placeholder); + } + }); + } } diff --git a/packages/components/tree-select/tree-select.component.ts b/packages/components/tree-select/tree-select.component.ts index 00017fda97..b8b8a8a689 100644 --- a/packages/components/tree-select/tree-select.component.ts +++ b/packages/components/tree-select/tree-select.component.ts @@ -51,14 +51,13 @@ import { HOME, KBQ_CONNECTED_OVERLAY_ABOVE_CLASS, KBQ_CONNECTED_OVERLAY_BELOW_CLASS, - KBQ_LOCALE_SERVICE, KBQ_PANEL_DEFAULT_MIN_WIDTH, KBQ_PARENT_POPUP, + KBQ_SELECT_LOCALE_CONFIGURATION, KBQ_SELECT_SCROLL_STRATEGY, KBQ_WINDOW, KbqAbstractSelect, KbqComponentColors, - KbqLocaleService, KbqPanelMaxHeight, KbqPanelMaxWidth, KbqPanelMinWidth, @@ -83,6 +82,7 @@ import { isInput, isSelectAll, isUndefined, + kbqInjectLocaleConfiguration, kbqResolvePanelMaxHeightToken, kbqSelectAnimations, kbqSiblingPopupProvider, @@ -246,7 +246,6 @@ export class KbqTreeSelect parentFormGroup = inject(FormGroupDirective, { optional: true }); private readonly parentFormField = inject(KBQ_FORM_FIELD, { host: true, optional: true })!; ngControl = inject(NgControl, { optional: true, self: true }); - private localeService = inject(KBQ_LOCALE_SERVICE, { optional: true }); protected readonly isBrowser = inject(Platform).isBrowser; private readonly defaultOptions = inject(KBQ_TREE_SELECT_OPTIONS, { optional: true }); @@ -354,8 +353,19 @@ export class KbqTreeSelect readonly search = contentChild(KbqSelectSearch); // TODO: Skipped for migration because: - // Your application code writes to the input. This prevents migration. - @Input() hiddenItemsText: string = '+{{ number }}'; + // Accessor inputs cannot be migrated as they are too complex. + @Input() + get hiddenItemsText(): string { + return this._hiddenItemsText ?? this.localeConfiguration().hiddenItemsText; + } + + set hiddenItemsText(value: string) { + this._hiddenItemsText = value; + } + + private _hiddenItemsText?: string; + + private readonly localeConfiguration = kbqInjectLocaleConfiguration('select', KBQ_SELECT_LOCALE_CONFIGURATION); /** * Event emitted when the select panel has been toggled. @@ -781,8 +791,6 @@ export class KbqTreeSelect constructor() { super(); - this.localeService?.changes.pipe(takeUntilDestroyed()).subscribe(this.updateLocaleParams); - // The tree owns the "select all" row — it is the only place that can put it in front of the nodes // and into the key manager's list. Mirrored through an effect rather than assigned once in // `ngAfterContentInit` so a `[selectAll]` bound to a changing expression keeps working. @@ -1450,12 +1458,6 @@ export class KbqTreeSelect ); } - private updateLocaleParams = () => { - this.hiddenItemsText = this.localeService?.getParams('select').hiddenItemsText; - - this.changeDetectorRef.markForCheck(); - }; - private closingActions() { const backdrop = this.overlayDir.overlayRef!.backdropClick(); const outsidePointerEvents = this.overlayDir diff --git a/packages/components/tree/tree-selection.component.ts b/packages/components/tree/tree-selection.component.ts index e2129dcdd5..ecaef00723 100644 --- a/packages/components/tree/tree-selection.component.ts +++ b/packages/components/tree/tree-selection.component.ts @@ -43,8 +43,8 @@ import { isCopy, isSelectAll, isVerticalMovement, - KBQ_LOCALE_SERVICE, - KbqLocaleService, + KBQ_SELECT_LOCALE_CONFIGURATION, + kbqInjectLocaleConfiguration, KbqPseudoCheckbox, KbqPseudoCheckboxState, KbqSelectAllAdapter, @@ -53,7 +53,6 @@ import { PAGE_DOWN, PAGE_UP, RIGHT_ARROW, - ruRULocaleData, SPACE, TAB, toggleSelectAll, @@ -307,8 +306,12 @@ export class KbqTreeSelection return getSelectAllState(this.selectAllAdapter); } - /** Label of the "select all" row. Kept in step with the locale service. */ - protected selectAllText: string = ruRULocaleData.select.selectAll; + /** Label of the "select all" row. Follows the active locale. */ + protected get selectAllText(): string { + return this.selectConfiguration().selectAll; + } + + private readonly selectConfiguration = kbqInjectLocaleConfiguration('select', KBQ_SELECT_LOCALE_CONFIGURATION); /** * Data nodes "select all" acts on, and the ones its checkbox state is derived from. @@ -410,16 +413,6 @@ export class KbqTreeSelection private optionBlurSubscription: Subscription | null; - private readonly localeService? = inject(KBQ_LOCALE_SERVICE, { optional: true }); - - /** Updates locale parameters from the locale service. */ - private updateLocaleParams = () => { - // Locale data registered by a consumer through `KBQ_LOCALE_DATA`/`addLocale` may predate this key. - this.selectAllText = this.localeService?.getParams('select')?.selectAll ?? ruRULocaleData.select.selectAll; - - this.changeDetectorRef.markForCheck(); - }; - constructor() { const multiple = inject(new HostAttributeToken('multiple'), { optional: true }); @@ -438,8 +431,6 @@ export class KbqTreeSelection this.selectionModel = new SelectionModel(this.multiple); - this.localeService?.changes.pipe(takeUntilDestroyed()).subscribe(this.updateLocaleParams); - // `unorderedOptions.changes` never fires for the "select all" row — it is a view child — so the // rendered list has to be rebuilt whenever the view query resolves or drops it. effect(() => { diff --git a/packages/docs-examples/components/file-upload/en-US.ts b/packages/docs-examples/components/file-upload/en-US.ts index 98c902dc21..7e2ba0a0df 100644 --- a/packages/docs-examples/components/file-upload/en-US.ts +++ b/packages/docs-examples/components/file-upload/en-US.ts @@ -1,6 +1,6 @@ -import { KbqMultipleFileUploadLocaleConfig } from '@koobiq/components/core'; +import { KbqMultipleFileUploadLocaleConfiguration } from '@koobiq/components/core'; -export const enUSLocaleData: KbqMultipleFileUploadLocaleConfig = { +export const enUSFileUploadLocaleData: KbqMultipleFileUploadLocaleConfiguration = { captionText: 'Drag here or {{ browseLink }}', captionTextOnlyFolder: 'Drag here or {{ browseLinkFolder }}', captionTextWithFolder: 'Drag here or {{ browseLink }} or {{ browseLinkFolderMixed }}', diff --git a/packages/docs-examples/components/file-upload/es-LA.ts b/packages/docs-examples/components/file-upload/es-LA.ts index 55d8bdb661..c24d71ae64 100644 --- a/packages/docs-examples/components/file-upload/es-LA.ts +++ b/packages/docs-examples/components/file-upload/es-LA.ts @@ -1,6 +1,6 @@ -import { KbqMultipleFileUploadLocaleConfig } from '@koobiq/components/core'; +import { KbqMultipleFileUploadLocaleConfiguration } from '@koobiq/components/core'; -export const esLALocaleData: KbqMultipleFileUploadLocaleConfig = { +export const esLAFileUploadLocaleData: KbqMultipleFileUploadLocaleConfiguration = { captionText: 'Arrastre aquí o {{ browseLink }}', captionTextWhenSelected: 'Arrastre más archivos aquí o {{ browseLink }}', captionTextOnlyFolder: 'Arrastre aquí o {{ browseLinkFolder }}', diff --git a/packages/docs-examples/components/file-upload/file-upload-custom-text-via-input/file-upload-custom-text-via-input-example.ts b/packages/docs-examples/components/file-upload/file-upload-custom-text-via-input/file-upload-custom-text-via-input-example.ts index a478f6b010..752ac15315 100644 --- a/packages/docs-examples/components/file-upload/file-upload-custom-text-via-input/file-upload-custom-text-via-input-example.ts +++ b/packages/docs-examples/components/file-upload/file-upload-custom-text-via-input/file-upload-custom-text-via-input-example.ts @@ -2,8 +2,8 @@ import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/c import { toSignal } from '@angular/core/rxjs-interop'; import { KBQ_LOCALE_SERVICE, - KbqBaseFileUploadLocaleConfig, - KbqMultipleFileUploadLocaleConfig + KbqBaseFileUploadLocaleConfiguration, + KbqMultipleFileUploadLocaleConfiguration } from '@koobiq/components/core'; import { KbqMultipleFileUploadComponent, KbqSingleFileUploadComponent } from '@koobiq/components/file-upload'; import { KbqIconModule } from '@koobiq/components/icon'; @@ -14,7 +14,7 @@ const localeData = { single: { captionText: 'Drop file here or {{ browseLink }}', browseLink: 'secure upload' - } satisfies Partial, + } satisfies Partial, multiple: { captionText: 'Drop reports here or {{ browseLink }}', @@ -22,14 +22,14 @@ const localeData = { captionTextForCompactSize: 'Attach logs or {{ browseLink }}', browseLink: 'secure upload', title: 'Submit security files' - } satisfies Partial + } satisfies Partial }, 'es-LA': { single: { captionText: 'Arrastra archivo aquí o {{ browseLink }}', browseLink: 'subida segura' - } satisfies Partial, + } satisfies Partial, multiple: { captionText: 'Arrastra reportes aquí o {{ browseLink }}', @@ -37,14 +37,14 @@ const localeData = { captionTextForCompactSize: 'Adjunta logs o {{ browseLink }}', browseLink: 'subida segura', title: 'Enviar archivos de seguridad' - } satisfies Partial + } satisfies Partial }, 'pt-BR': { single: { captionText: 'Arraste arquivo aqui ou {{ browseLink }}', browseLink: 'upload seguro' - } satisfies Partial, + } satisfies Partial, multiple: { captionText: 'Arraste relatórios aqui ou {{ browseLink }}', @@ -52,14 +52,14 @@ const localeData = { captionTextForCompactSize: 'Anexe logs ou {{ browseLink }}', browseLink: 'upload seguro', title: 'Enviar arquivos de segurança' - } satisfies Partial + } satisfies Partial }, 'ru-RU': { single: { captionText: 'Перетащите файл сюда или {{ browseLink }}', browseLink: 'безопасная загрузка' - } satisfies Partial, + } satisfies Partial, multiple: { captionText: 'Перетащите отчёты сюда или {{ browseLink }}', @@ -67,14 +67,14 @@ const localeData = { captionTextForCompactSize: 'Прикрепите логи или {{ browseLink }}', browseLink: 'безопасная загрузка', title: 'Загрузка файлов безопасности' - } satisfies Partial + } satisfies Partial }, 'tk-TM': { single: { captionText: 'Faýly şu ýere taşlaň ýa-da {{ browseLink }}', browseLink: 'howpsuz ýükleme' - } satisfies Partial, + } satisfies Partial, multiple: { captionText: 'Hasabatlary şu ýere taşlaň ýa-da {{ browseLink }}', @@ -82,7 +82,7 @@ const localeData = { captionTextForCompactSize: 'Loglary goşuň ýa-da {{ browseLink }}', browseLink: 'howpsuz ýükleme', title: 'Howpsuzlyk faýllaryny iber' - } satisfies Partial + } satisfies Partial } }; diff --git a/packages/docs-examples/components/file-upload/file-upload-multiple-custom-text-overview/file-upload-multiple-custom-text-overview-example.ts b/packages/docs-examples/components/file-upload/file-upload-multiple-custom-text-overview/file-upload-multiple-custom-text-overview-example.ts index 7500c00c3a..34ad8f1c37 100644 --- a/packages/docs-examples/components/file-upload/file-upload-multiple-custom-text-overview/file-upload-multiple-custom-text-overview-example.ts +++ b/packages/docs-examples/components/file-upload/file-upload-multiple-custom-text-overview/file-upload-multiple-custom-text-overview-example.ts @@ -1,20 +1,27 @@ import { ChangeDetectionStrategy, Component } from '@angular/core'; -import { KBQ_LOCALE_SERVICE, KbqLocaleService, KbqMultipleFileUploadLocaleConfig } from '@koobiq/components/core'; +import { + KBQ_DEFAULT_LOCALE_ID, + KBQ_LOCALE_SERVICE, + KbqLocaleService, + KbqMultipleFileUploadLocaleConfiguration +} from '@koobiq/components/core'; import { KBQ_FILE_UPLOAD_CONFIGURATION, KbqMultipleFileUploadComponent } from '@koobiq/components/file-upload'; import { KbqIconModule } from '@koobiq/components/icon'; -import { enUSLocaleData } from '../en-US'; -import { esLALocaleData } from '../es-LA'; -import { ptBRLocaleData } from '../pt-BR'; -import { ruRULocaleData } from '../ru-RU'; +import { enUSFileUploadLocaleData } from '../en-US'; +import { esLAFileUploadLocaleData } from '../es-LA'; +import { ptBRFileUploadLocaleData } from '../pt-BR'; +import { ruRUFileUploadLocaleData } from '../ru-RU'; +import { tkTMFileUploadLocaleData } from '../tk-TM'; -const localeData = { - 'en-US': enUSLocaleData, - 'es-LA': esLALocaleData, - 'pt-BR': ptBRLocaleData, - 'ru-RU': ruRULocaleData +const localeData: Record = { + 'en-US': enUSFileUploadLocaleData, + 'es-LA': esLAFileUploadLocaleData, + 'pt-BR': ptBRFileUploadLocaleData, + 'ru-RU': ruRUFileUploadLocaleData, + 'tk-TM': tkTMFileUploadLocaleData }; -class FileUploadConfiguration implements KbqMultipleFileUploadLocaleConfig { +class FileUploadConfiguration implements KbqMultipleFileUploadLocaleConfiguration { [k: string | number | symbol]: unknown; captionText: string; captionTextOnlyFolder: string; @@ -30,7 +37,8 @@ class FileUploadConfiguration implements KbqMultipleFileUploadLocaleConfig { } update = (locale: string) => { - const data: KbqMultipleFileUploadLocaleConfig = localeData[locale]; + // A consumer can register a locale this example knows nothing about. + const data = localeData[locale] ?? localeData[KBQ_DEFAULT_LOCALE_ID]; this.captionText = data.captionText; this.captionTextOnlyFolder = data.captionTextOnlyFolder; diff --git a/packages/docs-examples/components/file-upload/pt-BR.ts b/packages/docs-examples/components/file-upload/pt-BR.ts index 099e49207a..f720430800 100644 --- a/packages/docs-examples/components/file-upload/pt-BR.ts +++ b/packages/docs-examples/components/file-upload/pt-BR.ts @@ -1,6 +1,6 @@ -import { KbqMultipleFileUploadLocaleConfig } from '@koobiq/components/core'; +import { KbqMultipleFileUploadLocaleConfiguration } from '@koobiq/components/core'; -export const ptBRLocaleData: KbqMultipleFileUploadLocaleConfig = { +export const ptBRFileUploadLocaleData: KbqMultipleFileUploadLocaleConfiguration = { captionText: 'Arrastar aqui ou {{ browseLink }}', captionTextWhenSelected: 'Arrastar arquivos ou {{ browseLink }}', captionTextWithFolder: 'Arrastar aqui ou {{ browseLink }} ou {{ browseLinkFolderMixed }}', diff --git a/packages/docs-examples/components/file-upload/ru-RU.ts b/packages/docs-examples/components/file-upload/ru-RU.ts index 8f2367aaaa..d62870c8f6 100644 --- a/packages/docs-examples/components/file-upload/ru-RU.ts +++ b/packages/docs-examples/components/file-upload/ru-RU.ts @@ -1,6 +1,6 @@ -import { KbqMultipleFileUploadLocaleConfig } from '@koobiq/components/core'; +import { KbqMultipleFileUploadLocaleConfiguration } from '@koobiq/components/core'; -export const ruRULocaleData: KbqMultipleFileUploadLocaleConfig = { +export const ruRUFileUploadLocaleData: KbqMultipleFileUploadLocaleConfiguration = { captionText: 'или {{ browseLink }}', captionTextOnlyFolder: 'или {{ browseLinkFolder }}', captionTextWithFolder: 'или {{ browseLink }} или {{ browseLinkFolderMixed }}', diff --git a/packages/docs-examples/components/file-upload/tk-TM.ts b/packages/docs-examples/components/file-upload/tk-TM.ts new file mode 100644 index 0000000000..7748bc2b23 --- /dev/null +++ b/packages/docs-examples/components/file-upload/tk-TM.ts @@ -0,0 +1,17 @@ +import { KbqMultipleFileUploadLocaleConfiguration } from '@koobiq/components/core'; + +/** + * Mirrors the strings the library ships for `tk-TM` rather than inventing new ones — the example only + * needs a complete entry for every locale the picker offers. + */ +export const tkTMFileUploadLocaleData: KbqMultipleFileUploadLocaleConfiguration = { + captionText: 'Şu ýere geçiriň ýa-da {{ browseLink }}', + captionTextOnlyFolder: 'Şu ýere geçiriň ýa-da {{ browseLinkFolder }}', + captionTextWithFolder: 'Şu ýere geçiriň ýa-da {{ browseLink }} ýa-da {{ browseLinkFolderMixed }}', + captionTextWhenSelected: 'Ýene geçiriň ýa-da {{ browseLink }}', + captionTextForCompactSize: 'Faýllary geçiriň ýa-da {{ browseLink }}', + browseLink: 'saýlaň', + browseLinkFolder: 'bukja', + browseLinkFolderMixed: 'bukja', + title: 'Faýl ýükläň' +}; diff --git a/packages/schematics/scripts/copy-meta-to-dist.js b/packages/schematics/scripts/copy-meta-to-dist.js index 2948353bb1..780f33cf01 100644 --- a/packages/schematics/scripts/copy-meta-to-dist.js +++ b/packages/schematics/scripts/copy-meta-to-dist.js @@ -49,10 +49,16 @@ const init = async () => { resolvePath(`../src/migrations/${migration}/schema.json`), join(migrationPath, 'schema.json') ); - await copyFileWrapper( - resolvePath(`../src/migrations/${migration}/README.md`), - join(migrationPath, 'README.md') - ); + // Optional, like `data.js` below. A migration that never got one must not abort the loop: the + // copies that follow it include `utils/`, which every built migration requires at runtime. + const migrationReadme = resolvePath(`../src/migrations/${migration}/README.md`); + + if (statSync(migrationReadme, { throwIfNoEntry: false })) { + await copyFileWrapper(migrationReadme, join(migrationPath, 'README.md')); + } else { + console.warn(`No README.md for the "${migration}" migration — it ships without one.`); + } + await copyFileWrapper(resolvePath(`../dist/migrations/${migration}/index.js`), join(migrationPath, 'index.js')); const optionalMigrationData = resolvePath(`../dist/migrations/${migration}/data.js`); const fileExists = statSync(optionalMigrationData, { throwIfNoEntry: false }); @@ -78,4 +84,10 @@ const init = async () => { await copyFileWrapper(resolvePath('../dist/utils/angular-parsing.js'), join(utilsPath, 'angular-parsing.js')); }; -init().catch((error) => console.error(`Failed to initialize directories and copy files: ${error.message}`)); +// A non-zero exit is what keeps a half-copied `schematics/` from shipping: the files this script places +// are what `ng update` loads, and a build that only logged the failure stayed green while the published +// package could no longer run a single migration. +init().catch((error) => { + console.error(`Failed to initialize directories and copy files: ${error.message}`); + process.exitCode = 1; +}); diff --git a/packages/schematics/src/collection.json b/packages/schematics/src/collection.json index aa72890ed8..9452740280 100644 --- a/packages/schematics/src/collection.json +++ b/packages/schematics/src/collection.json @@ -106,6 +106,11 @@ "description": "Rewrites @koobiq/components/scrollbar imports to @koobiq/components/scrollbar/deprecated — the overlayscrollbars-based implementation moved there when the dependency-free implementation was promoted to @koobiq/components/scrollbar", "factory": "./migrations/scrollbar-deprecated-path/index", "schema": "./migrations/scrollbar-deprecated-path/schema.json" + }, + "locale-configuration-providers": { + "description": "Rewrites { provide: KBQ__CONFIGURATION, useValue: … } provider entries of the navbar, notification-center, app-switcher, search-expandable, datepicker and filter-bar to the kbqLocaleConfigurationProvider() helpers, which is what still overrides the locale now that those tokens carry the defaults only, and warns on the provider shapes it cannot rewrite plus the removed externalConfiguration and read-only configuration members", + "factory": "./migrations/locale-configuration-providers/index", + "schema": "./migrations/locale-configuration-providers/schema.json" } } } diff --git a/packages/schematics/src/migrations.json b/packages/schematics/src/migrations.json index bb45c8e5fa..c79858daf3 100644 --- a/packages/schematics/src/migrations.json +++ b/packages/schematics/src/migrations.json @@ -70,6 +70,11 @@ "version": "20.3.0-0", "description": "Rewrites `@koobiq/components/scrollbar` imports to `@koobiq/components/scrollbar/deprecated`. `@koobiq/components/scrollbar` now resolves to a new, dependency-free scrollbar directive; the previous `overlayscrollbars`-based component/directive (`options`/`events`/`defer`/`scrollbarInstance`, the `kbq-scrollbar` element selector) moved to `@koobiq/components/scrollbar/deprecated` unchanged and will be removed in a future major version.", "factory": "./migrations/scrollbar-deprecated-path/index" + }, + "locale-configuration-providers": { + "version": "20.3.0-0", + "description": "Migrates consumers of the six components whose locale resolution was inverted — kbq-vertical-navbar, kbq-notification-center, kbq-app-switcher, kbq-search-expandable, the datepicker input and kbq-filter-bar. A value provided for KBQ__CONFIGURATION used to beat KBQ_LOCALE_SERVICE outright; the token now supplies the defaults only, the active locale wins, and consumer overrides are merged on top from a separate multi token, so such a provider silently stops taking effect. Rewrites `{ provide: KBQ__CONFIGURATION, useValue: }` entries of a provider array to `kbqLocaleConfigurationProvider()`, adding the helper to the module's existing import clause and dropping the token from it once nothing else refers to it. Warns — without rewriting — on the same provider written with useFactory/useClass/useExisting (the helper takes a value), on a provider object that is not an array element (replacing it would change what the name it is bound to means), on every token reference left after the rewrite pass, and on reads of the removed externalConfiguration member and assignments to the now read-only configuration getter.", + "factory": "./migrations/locale-configuration-providers/index" } } } diff --git a/packages/schematics/src/migrations/locale-configuration-providers/README.md b/packages/schematics/src/migrations/locale-configuration-providers/README.md new file mode 100644 index 0000000000..633b89b93f --- /dev/null +++ b/packages/schematics/src/migrations/locale-configuration-providers/README.md @@ -0,0 +1,150 @@ +# locale-configuration-providers + +Migration schematic invoked automatically by `ng update @koobiq/components@20` +(registered for `20.3.0-0`). Moves `KBQ__CONFIGURATION` value providers to the +`kbqLocaleConfigurationProvider()` helpers. + +## Background + +Six components resolved their localized strings themselves: + +```ts +this.configuration = + this.externalConfiguration ?? this.localeService?.getParams('navbar') ?? KBQ_VERTICAL_NAVBAR_DEFAULT_CONFIGURATION; +``` + +A value provided for `KBQ__CONFIGURATION` therefore won **outright** over +`KBQ_LOCALE_SERVICE`. They now read the shared +`kbqInjectLocaleConfiguration(section, token)` helper, in which: + +- the token supplies the **defaults only** — it gained a `factory`, so it always + has a value and never reaches the component as `null`; +- the active locale wins over those defaults; +- consumer overrides are merged on top, from a separate multi token that + `kbqLocaleConfigurationProvider()` writes to. + +## Behaviour change + +**`{ provide: KBQ__CONFIGURATION, useValue: … }` silently stops taking effect** +in any application that provides `KBQ_LOCALE_SERVICE`: it still compiles, still +injects, and is simply outranked by the locale. The replacement helper registers +a real override. + +The override is also a deep partial now: the strings you do not pass keep +following the active locale instead of falling back to the Russian defaults. + +## Affected tokens + +| Token | Replacement | Module | +| --------------------------------------- | ---------------------------------------------------- | ---------------------------------------- | +| `KBQ_VERTICAL_NAVBAR_CONFIGURATION` | `kbqVerticalNavbarLocaleConfigurationProvider()` | `@koobiq/components/navbar` | +| `KBQ_NOTIFICATION_CENTER_CONFIGURATION` | `kbqNotificationCenterLocaleConfigurationProvider()` | `@koobiq/components/notification-center` | +| `KBQ_APP_SWITCHER_CONFIGURATION` | `kbqAppSwitcherLocaleConfigurationProvider()` | `@koobiq/components/app-switcher` | +| `KBQ_SEARCH_EXPANDABLE_CONFIGURATION` | `kbqSearchExpandableLocaleConfigurationProvider()` | `@koobiq/components/search-expandable` | +| `KBQ_DATEPICKER_CONFIGURATION` | `kbqDatepickerLocaleConfigurationProvider()` | `@koobiq/components/datepicker` | +| `KBQ_FILTER_BAR_CONFIGURATION` | `kbqFilterBarLocaleConfigurationProvider()` | `@koobiq/components/filter-bar` | + +## What it does + +The schematic walks every `.ts` and `.html` file in the project (skipping +`node_modules` and `dist`). + +| Auto-fix | Where | +| -------------------------------------------------------------------------------------- | ----- | +| Rewrites `{ provide: , useValue: }` array elements to `()` | `.ts` | +| Adds `` to an existing import clause of the module, or inserts a new import | `.ts` | +| Removes `` from its import clause once nothing else in the file refers to it | `.ts` | + +Provider entries are found through the TypeScript AST, so property order does not +matter and `{ 'provide': …, 'useValue': … }` with string keys is recognised too. +`` is copied verbatim — an identifier, an object literal, a call, anything. + +The helper joins an existing clause of the module **before** the token is removed, +so the import keeps its place in the file even when the token was the only symbol +in the clause it lived in. + +## What it does _not_ do (warn-only) + +| Pattern | Manual migration | +| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| `{ provide: , useFactory / useClass / useExisting: … }` | The helper takes the configuration by value — resolve the factory/class/alias yourself and pass the result | +| A provider object that is not an array element | `export const P = { provide: , useValue: … };` is not an element of anything, and the helper returns a `Provider`, not an object literal | +| Any `` reference left after the rewrite pass | An `inject()` call, a re-export, or a provider shape the helper could not take over — providing the token now changes the defaults only | +| `.externalConfiguration` | The member was removed. Read `configuration`, which already merges the token defaults, the active locale and every registered override | +| `.configuration = …` | `configuration` is a read-only getter over a signal. Register the strings with the matching `kbqLocaleConfigurationProvider()` instead | + +A provider reported by one of the two specific messages is not reported again by +the generic leftover-token one. The `.configuration = …` pattern is common enough +outside Koobiq that it is only reported in files that mention one of the six +components. + +Warnings are checked against the **post-fix** content, so an auto-fixed usage +does not also report as needing manual work. In dry-run mode (`--fix false`) they +are reported against the original content instead. + +`fix` defaults to `true`. `ng update` invokes migrations with no options at all, +so the rule applies that default itself rather than relying on the schema. + +[Params](schema.ts) + +Usage for Angular Cli: + +```shell +ng g @koobiq/components:locale-configuration-providers --project +``` + +Usage for Nx: + +```shell +nx g @koobiq/components:locale-configuration-providers --project +``` + +### Run locally + +Build package + +```shell +yarn run build:schematics +``` + +Run command (for example, for `koobiq-docs` project) + +```shell +ng g ./dist/components/schematics/collection.json:locale-configuration-providers --project koobiq-docs +``` + +### Result + +#### Before + +```ts +import { Component } from '@angular/core'; +import { KBQ_FILTER_BAR_CONFIGURATION, KbqFilterBarModule } from '@koobiq/components/filter-bar'; + +@Component({ + selector: 'my-page', + imports: [KbqFilterBarModule], + providers: [{ provide: KBQ_FILTER_BAR_CONFIGURATION, useValue: myFilterBarStrings }], + template: ` + + ` +}) +export class MyPage {} +``` + +#### After + +```ts +import { Component } from '@angular/core'; +import { KbqFilterBarModule, kbqFilterBarLocaleConfigurationProvider } from '@koobiq/components/filter-bar'; + +@Component({ + selector: 'my-page', + imports: [KbqFilterBarModule], + providers: [kbqFilterBarLocaleConfigurationProvider(myFilterBarStrings)], + template: ` + + ` +}) +export class MyPage {} +``` diff --git a/packages/schematics/src/migrations/locale-configuration-providers/data.ts b/packages/schematics/src/migrations/locale-configuration-providers/data.ts new file mode 100644 index 0000000000..6e351864b4 --- /dev/null +++ b/packages/schematics/src/migrations/locale-configuration-providers/data.ts @@ -0,0 +1,157 @@ +/** + * Replacement data for the locale-configuration provider change. + * + * Six components used to resolve their strings as + * `externalConfiguration ?? localeService.getParams(section) ?? DEFAULT`, so a value provided for + * `KBQ__CONFIGURATION` won outright over the locale service. They now read the shared + * `kbqInjectLocaleConfiguration(section, token)` helper, in which the token supplies only the + * defaults (it gained a `factory`) and the active locale wins, with consumer overrides merged on top + * from a separate multi token. A plain `{ provide: KBQ__CONFIGURATION, useValue: … }` therefore + * stops taking effect in any application that provides `KBQ_LOCALE_SERVICE` — silently, because it + * still compiles and still injects. `kbqLocaleConfigurationProvider(…)` registers a real override. + */ + +export interface WarnPattern { + pattern: string; + message: string; + /** + * Report only in a file that mentions one of {@link COMPONENT_MENTIONS}. Set on patterns whose shape + * is common enough to match unrelated code. + */ + needsComponentMention?: boolean; +} + +/** A configuration token whose `useValue` provider entries are rewritten to its override helper. */ +export interface MigratedProviderToken { + /** Configuration token that now carries the defaults only. */ + token: string; + /** Provider helper that registers an override the locale service cannot outrank. */ + helper: string; + /** Module both the token and the helper are exported from. */ + from: string; +} + +export const MIGRATED_PROVIDER_TOKENS: MigratedProviderToken[] = [ + { + token: 'KBQ_VERTICAL_NAVBAR_CONFIGURATION', + helper: 'kbqVerticalNavbarLocaleConfigurationProvider', + from: '@koobiq/components/navbar' + }, + { + token: 'KBQ_NOTIFICATION_CENTER_CONFIGURATION', + helper: 'kbqNotificationCenterLocaleConfigurationProvider', + from: '@koobiq/components/notification-center' + }, + { + token: 'KBQ_APP_SWITCHER_CONFIGURATION', + helper: 'kbqAppSwitcherLocaleConfigurationProvider', + from: '@koobiq/components/app-switcher' + }, + { + token: 'KBQ_SEARCH_EXPANDABLE_CONFIGURATION', + helper: 'kbqSearchExpandableLocaleConfigurationProvider', + from: '@koobiq/components/search-expandable' + }, + { + token: 'KBQ_DATEPICKER_CONFIGURATION', + helper: 'kbqDatepickerLocaleConfigurationProvider', + from: '@koobiq/components/datepicker' + }, + { + token: 'KBQ_FILTER_BAR_CONFIGURATION', + helper: 'kbqFilterBarLocaleConfigurationProvider', + from: '@koobiq/components/filter-bar' + } +]; + +/** The `provide` key of a provider object literal, matched as an identifier or as a string key. */ +export const PROVIDE_PROPERTY = 'provide'; + +/** The only provider shape the helper can take over — it accepts the configuration by value. */ +export const VALUE_PROPERTY = 'useValue'; + +/** Provider shapes that need a human: the helper takes a value, not a factory, a class or an alias. */ +export const UNSUPPORTED_PROPERTIES = ['useFactory', 'useClass', 'useExisting']; + +/** + * Substrings that make a file plausibly about one of the six components. The member warnings are + * property-name based, and `.configuration = …` is far too common a shape to report in a file that never + * mentions the components the member belongs to. + */ +export const COMPONENT_MENTIONS = [ + 'KbqVerticalNavbar', + 'kbq-vertical-navbar', + 'KbqNotificationCenter', + 'kbq-notification-center', + 'kbqNotificationCenterTrigger', + 'KbqAppSwitcher', + 'kbq-app-switcher', + 'kbqAppSwitcher', + 'KbqSearchExpandable', + 'kbq-search-expandable', + 'KbqDatepicker', + 'kbqDatepicker', + 'KbqFilterBar', + 'kbq-filter-bar' +]; + +export function unsupportedShapeMessage({ token, helper }: MigratedProviderToken, property: string): string { + return ( + `${token} is now a defaults-only token, so this ${property} provider no longer overrides the ` + + `active locale. ${helper}() takes the configuration by value — resolve it yourself and pass the ` + + 'result, or keep the provider as it is if changing the defaults only is what you meant.' + ); +} + +export function nonArrayProviderMessage({ token, helper }: MigratedProviderToken): string { + return ( + `${token} is now a defaults-only token, so this provider object no longer overrides the active ` + + `locale. It is not an element of a provider array, so it was left alone: replace it with ` + + `${helper}() by hand — the helper returns a Provider, not an object literal.` + ); +} + +export function leftoverTokenMessage({ token, helper }: MigratedProviderToken): string { + return ( + `${token} now supplies the defaults only — the active locale wins over it, and consumer ` + + `overrides are registered through ${helper}(). Review this usage: providing the token no longer ` + + 'changes the rendered strings in an application that provides KBQ_LOCALE_SERVICE.' + ); +} + +/** + * Warnings for `.ts` files and templates. Checked against the post-fix content, so they only fire on + * what the auto-fix could not handle. + */ +export const memberWarnPatterns: WarnPattern[] = [ + { + pattern: '\\.externalConfiguration\\b', + message: + 'The externalConfiguration member was removed from KbqVerticalNavbar, KbqNotificationCenterComponent, ' + + 'KbqAppSwitcherComponent, KbqSearchExpandable, KbqDatepickerInput and KbqFilterBar. There is no ' + + 'separate external configuration any more — read `configuration`, which already merges the token ' + + 'defaults, the active locale and every registered override.' + }, + { + pattern: '\\.configuration\\s*=(?!=)', + needsComponentMention: true, + message: + 'The configuration member of KbqVerticalNavbar, KbqNotificationCenterComponent, ' + + 'KbqAppSwitcherComponent, KbqSearchExpandable, KbqDatepickerInput and KbqFilterBar is a read-only ' + + 'getter over a signal. If the receiver is one of them, register the strings with the matching ' + + 'kbqLocaleConfigurationProvider() instead of assigning to the member.' + } +]; + +/** + * Behaviour note printed once per run. The change is not purely mechanical: the resolution order was + * inverted, so an application can be affected without ever having provided one of the tokens. + */ +export const BEHAVIOUR_NOTE = [ + 'Locale resolution order changed for kbq-vertical-navbar, kbq-notification-center, kbq-app-switcher,', + 'kbq-search-expandable, the datepicker input and kbq-filter-bar. A KBQ__CONFIGURATION value used to', + 'beat KBQ_LOCALE_SERVICE outright; the token now supplies the defaults only, the active locale wins,', + 'and consumer overrides are merged on top from kbqLocaleConfigurationProvider().', + 'An override is now a deep partial: the strings you do not pass keep following the locale instead of', + 'falling back to the Russian defaults.' +]; diff --git a/packages/schematics/src/migrations/locale-configuration-providers/index.spec.ts b/packages/schematics/src/migrations/locale-configuration-providers/index.spec.ts new file mode 100644 index 0000000000..e17cc18426 --- /dev/null +++ b/packages/schematics/src/migrations/locale-configuration-providers/index.spec.ts @@ -0,0 +1,571 @@ +import { workspaces } from '@angular-devkit/core'; +import { Tree } from '@angular-devkit/schematics'; +import { SchematicTestRunner } from '@angular-devkit/schematics/testing'; +import { getWorkspace } from '@schematics/angular/utility/workspace'; +import * as path from 'path'; +import { createTestApp } from '../../utils/testing'; +import { Schema } from './schema'; + +const collectionPath = path.join(__dirname, '../../collection.json'); +const migrationsPath = path.join(__dirname, '../../migrations.json'); +const SCHEMATIC_NAME = 'locale-configuration-providers'; + +describe(SCHEMATIC_NAME, () => { + let runner: SchematicTestRunner; + let appTree: Tree; + let projects: workspaces.ProjectDefinitionCollection; + + beforeEach(async () => { + runner = new SchematicTestRunner('schematics', collectionPath); + appTree = await createTestApp(runner, { style: 'scss' }); + + const workspace = await getWorkspace(appTree); + + projects = workspace.projects as unknown as workspaces.ProjectDefinitionCollection; + }); + + function paths(project: workspaces.ProjectDefinition) { + // The exact file names from @schematics/angular:application vary across versions + // (app.ts vs app.component.ts), so discover them from the tree. + const root = `/${project.root}/src/app`; + const ts = appTree.exists(`${root}/app.ts`) ? `${root}/app.ts` : `${root}/app.component.ts`; + const html = appTree.exists(`${root}/app.html`) ? `${root}/app.html` : `${root}/app.component.html`; + + return { ts, html }; + } + + function run(project: string, fix = true) { + return runner.runSchematic(SCHEMATIC_NAME, { project, fix } satisfies Schema, appTree); + } + + function collectLogs(): string[] { + const messages: string[] = []; + + runner.logger.subscribe((entry) => messages.push(entry.message)); + + return messages; + } + + describe('provider rewrite', () => { + it('rewrites a useValue provider of a providers array to the helper call', async () => { + const [first] = projects.keys(); + const { ts } = paths(projects.get(first)!); + + appTree.overwrite( + ts, + "import { Component } from '@angular/core';\n" + + "import { KBQ_FILTER_BAR_CONFIGURATION } from '@koobiq/components/filter-bar';\n" + + '@Component({\n' + + " selector: 'my-page',\n" + + ' providers: [{ provide: KBQ_FILTER_BAR_CONFIGURATION, useValue: myStrings }],\n' + + ' template: ``\n' + + '})\n' + + 'export class MyPage {}\n' + ); + + const updated = (await run(first)).readText(ts); + + expect(updated).toBe( + "import { Component } from '@angular/core';\n" + + "import { kbqFilterBarLocaleConfigurationProvider } from '@koobiq/components/filter-bar';\n" + + '@Component({\n' + + " selector: 'my-page',\n" + + ' providers: [kbqFilterBarLocaleConfigurationProvider(myStrings)],\n' + + ' template: ``\n' + + '})\n' + + 'export class MyPage {}\n' + ); + }); + + it('preserves an object-literal value verbatim, across lines', async () => { + const [first] = projects.keys(); + const { ts } = paths(projects.get(first)!); + + appTree.overwrite( + ts, + "import { KBQ_VERTICAL_NAVBAR_CONFIGURATION } from '@koobiq/components/navbar';\n" + + 'const providers = [\n' + + ' {\n' + + ' provide: KBQ_VERTICAL_NAVBAR_CONFIGURATION,\n' + + ' useValue: {\n' + + " collapse: 'Collapse',\n" + + " expand: 'Expand'\n" + + ' }\n' + + ' }\n' + + '];\n' + ); + + const updated = (await run(first)).readText(ts); + + expect(updated).toContain( + 'kbqVerticalNavbarLocaleConfigurationProvider({\n' + + " collapse: 'Collapse',\n" + + " expand: 'Expand'\n" + + ' })' + ); + }); + + it('ignores property order and a string-literal provide key', async () => { + const [first] = projects.keys(); + const { ts } = paths(projects.get(first)!); + + appTree.overwrite( + ts, + "import { KBQ_APP_SWITCHER_CONFIGURATION } from '@koobiq/components/app-switcher';\n" + + "const providers = [{ useValue: strings, 'provide': KBQ_APP_SWITCHER_CONFIGURATION }];\n" + ); + + const updated = (await run(first)).readText(ts); + + expect(updated).toContain('const providers = [kbqAppSwitcherLocaleConfigurationProvider(strings)];'); + }); + + it('rewrites two different tokens in the same array', async () => { + const [first] = projects.keys(); + const { ts } = paths(projects.get(first)!); + + appTree.overwrite( + ts, + "import { KBQ_NOTIFICATION_CENTER_CONFIGURATION } from '@koobiq/components/notification-center';\n" + + "import { KBQ_SEARCH_EXPANDABLE_CONFIGURATION } from '@koobiq/components/search-expandable';\n" + + 'const providers = [\n' + + ' { provide: KBQ_NOTIFICATION_CENTER_CONFIGURATION, useValue: notifications },\n' + + ' { provide: KBQ_SEARCH_EXPANDABLE_CONFIGURATION, useValue: search }\n' + + '];\n' + ); + + const updated = (await run(first)).readText(ts); + + expect(updated).toBe( + 'import { kbqNotificationCenterLocaleConfigurationProvider } from ' + + "'@koobiq/components/notification-center';\n" + + 'import { kbqSearchExpandableLocaleConfigurationProvider } from ' + + "'@koobiq/components/search-expandable';\n" + + 'const providers = [\n' + + ' kbqNotificationCenterLocaleConfigurationProvider(notifications),\n' + + ' kbqSearchExpandableLocaleConfigurationProvider(search)\n' + + '];\n' + ); + }); + + it('keeps a call expression value intact', async () => { + const [first] = projects.keys(); + const { ts } = paths(projects.get(first)!); + + appTree.overwrite( + ts, + "import { KBQ_DATEPICKER_CONFIGURATION } from '@koobiq/components/datepicker';\n" + + 'const providers = [{ provide: KBQ_DATEPICKER_CONFIGURATION, useValue: buildStrings(locale) }];\n' + ); + + const updated = (await run(first)).readText(ts); + + expect(updated).toContain('kbqDatepickerLocaleConfigurationProvider(buildStrings(locale))'); + }); + + it('keeps sibling providers and does not reformat the array', async () => { + const [first] = projects.keys(); + const { ts } = paths(projects.get(first)!); + + appTree.overwrite( + ts, + "import { KBQ_FILTER_BAR_CONFIGURATION } from '@koobiq/components/filter-bar';\n" + + 'const providers = [\n' + + ' A,\n' + + ' { provide: KBQ_FILTER_BAR_CONFIGURATION, useValue: strings },\n' + + ' B\n' + + '];\n' + ); + + const updated = (await run(first)).readText(ts); + + expect(updated).toContain( + 'const providers = [\n' + + ' A,\n' + + ' kbqFilterBarLocaleConfigurationProvider(strings),\n' + + ' B\n' + + '];\n' + ); + }); + }); + + describe('imports', () => { + it('drops the token from a shared clause and keeps the other symbols', async () => { + const [first] = projects.keys(); + const { ts } = paths(projects.get(first)!); + + appTree.overwrite( + ts, + 'import { KBQ_FILTER_BAR_CONFIGURATION, KbqFilterBarModule } from ' + + "'@koobiq/components/filter-bar';\n" + + 'const providers = [{ provide: KBQ_FILTER_BAR_CONFIGURATION, useValue: strings }];\n' + ); + + const updated = (await run(first)).readText(ts); + + expect(updated).toContain( + 'import { KbqFilterBarModule, kbqFilterBarLocaleConfigurationProvider } from ' + + "'@koobiq/components/filter-bar';" + ); + expect(updated).not.toContain('KBQ_FILTER_BAR_CONFIGURATION'); + }); + + it('adds the helper to an existing clause of the same module written on another line', async () => { + const [first] = projects.keys(); + const { ts } = paths(projects.get(first)!); + + appTree.overwrite( + ts, + "import { KbqNavbarModule } from '@koobiq/components/navbar';\n" + + "import { KBQ_VERTICAL_NAVBAR_CONFIGURATION } from '@koobiq/components/navbar';\n" + + 'const providers = [{ provide: KBQ_VERTICAL_NAVBAR_CONFIGURATION, useValue: strings }];\n' + ); + + const updated = (await run(first)).readText(ts); + + expect(updated).toBe( + 'import { KbqNavbarModule, kbqVerticalNavbarLocaleConfigurationProvider } from ' + + "'@koobiq/components/navbar';\n" + + 'const providers = [kbqVerticalNavbarLocaleConfigurationProvider(strings)];\n' + ); + }); + + it('adds the helper to the value clause when a type-only clause of the same module precedes it', async () => { + const [first] = projects.keys(); + const { ts } = paths(projects.get(first)!); + + appTree.overwrite( + ts, + "import type { KbqFilterBarConfiguration } from '@koobiq/components/filter-bar';\n" + + 'import { KBQ_FILTER_BAR_CONFIGURATION, KbqFilterBarModule } from ' + + "'@koobiq/components/filter-bar';\n" + + 'const providers = [{ provide: KBQ_FILTER_BAR_CONFIGURATION, useValue: strings }];\n' + ); + + const updated = (await run(first)).readText(ts); + + // The helper is called as a value, so landing it in the type-only clause would be a compile error. + expect(updated).toBe( + "import type { KbqFilterBarConfiguration } from '@koobiq/components/filter-bar';\n" + + 'import { KbqFilterBarModule, kbqFilterBarLocaleConfigurationProvider } from ' + + "'@koobiq/components/filter-bar';\n" + + 'const providers = [kbqFilterBarLocaleConfigurationProvider(strings)];\n' + ); + }); + + it('inserts a new import when the module is not imported by name', async () => { + const [first] = projects.keys(); + const { ts } = paths(projects.get(first)!); + + appTree.overwrite( + ts, + "import * as filterBar from '@koobiq/components/filter-bar';\n" + + 'const providers = [{ provide: filterBar.KBQ_FILTER_BAR_CONFIGURATION, useValue: strings }];\n' + + 'const other = [{ provide: KBQ_FILTER_BAR_CONFIGURATION, useValue: strings }];\n' + ); + + const updated = (await run(first)).readText(ts); + + expect(updated).toContain( + "import { kbqFilterBarLocaleConfigurationProvider } from '@koobiq/components/filter-bar';" + ); + // A namespace access is not an identifier reference the AST pass matches. + expect(updated).toContain('{ provide: filterBar.KBQ_FILTER_BAR_CONFIGURATION, useValue: strings }'); + expect(updated).toContain('const other = [kbqFilterBarLocaleConfigurationProvider(strings)];'); + }); + + it('keeps the token import when another reference to it remains', async () => { + const [first] = projects.keys(); + const { ts } = paths(projects.get(first)!); + + appTree.overwrite( + ts, + "import { KBQ_FILTER_BAR_CONFIGURATION } from '@koobiq/components/filter-bar';\n" + + 'const providers = [{ provide: KBQ_FILTER_BAR_CONFIGURATION, useValue: strings }];\n' + + 'const defaults = inject(KBQ_FILTER_BAR_CONFIGURATION);\n' + ); + + const updated = (await run(first)).readText(ts); + + expect(updated).toContain( + 'import { KBQ_FILTER_BAR_CONFIGURATION, kbqFilterBarLocaleConfigurationProvider } from ' + + "'@koobiq/components/filter-bar';" + ); + expect(updated).toContain('const providers = [kbqFilterBarLocaleConfigurationProvider(strings)];'); + }); + + it('keeps the blank line that follows the dropped import line', async () => { + const [first] = projects.keys(); + const { ts } = paths(projects.get(first)!); + + appTree.overwrite( + ts, + "import { Component } from '@angular/core';\n" + + "import { KBQ_FILTER_BAR_CONFIGURATION } from '@koobiq/components/filter-bar';\n" + + '\n' + + 'const providers = [{ provide: KBQ_FILTER_BAR_CONFIGURATION, useValue: strings }];\n' + ); + + const updated = (await run(first)).readText(ts); + + expect(updated).toBe( + "import { Component } from '@angular/core';\n" + + "import { kbqFilterBarLocaleConfigurationProvider } from '@koobiq/components/filter-bar';\n" + + '\n' + + 'const providers = [kbqFilterBarLocaleConfigurationProvider(strings)];\n' + ); + }); + }); + + describe('warnings', () => { + it('leaves a useFactory provider alone and reports it', async () => { + const [first] = projects.keys(); + const { ts } = paths(projects.get(first)!); + const original = + "import { KBQ_FILTER_BAR_CONFIGURATION } from '@koobiq/components/filter-bar';\n" + + 'const providers = [{ provide: KBQ_FILTER_BAR_CONFIGURATION, useFactory: () => strings }];\n'; + const messages = collectLogs(); + + appTree.overwrite(ts, original); + + expect((await run(first)).readText(ts)).toBe(original); + expect(messages.join('\n')).toContain('useFactory provider no longer overrides the active locale'); + }); + + it.each(['useClass: StringsFactory', 'useExisting: OTHER_TOKEN'])( + 'leaves a %s provider alone and reports it', + async (property) => { + const [first] = projects.keys(); + const { ts } = paths(projects.get(first)!); + const original = + "import { KBQ_APP_SWITCHER_CONFIGURATION } from '@koobiq/components/app-switcher';\n" + + `const providers = [{ provide: KBQ_APP_SWITCHER_CONFIGURATION, ${property} }];\n`; + const messages = collectLogs(); + + appTree.overwrite(ts, original); + + expect((await run(first)).readText(ts)).toBe(original); + expect(messages.join('\n')).toContain('KBQ_APP_SWITCHER_CONFIGURATION is now a defaults-only token'); + } + ); + + // Replacing it would leave the name bound to a Provider instead of an object literal. + it('leaves a named-const provider alone and reports it', async () => { + const [first] = projects.keys(); + const { ts } = paths(projects.get(first)!); + const original = + "import { KBQ_FILTER_BAR_CONFIGURATION } from '@koobiq/components/filter-bar';\n\n" + + 'export const FILTER_BAR_STRINGS = { provide: KBQ_FILTER_BAR_CONFIGURATION, useValue: strings };\n'; + const messages = collectLogs(); + + appTree.overwrite(ts, original); + + expect((await run(first)).readText(ts)).toBe(original); + expect(messages.join('\n')).toContain('It is not an element of a provider array'); + }); + + it('reports a token reference the rewrite could not remove only once', async () => { + const [first] = projects.keys(); + const { ts } = paths(projects.get(first)!); + const messages = collectLogs(); + + appTree.overwrite( + ts, + "import { KBQ_FILTER_BAR_CONFIGURATION } from '@koobiq/components/filter-bar';\n\n" + + 'export const FILTER_BAR_STRINGS = { provide: KBQ_FILTER_BAR_CONFIGURATION, useValue: strings };\n' + ); + + await run(first); + + const log = messages.join('\n'); + + expect(log).toContain('It is not an element of a provider array'); + expect(log).not.toContain('KBQ_FILTER_BAR_CONFIGURATION now supplies the defaults only'); + }); + + it('reports a leftover inject() of the token', async () => { + const [first] = projects.keys(); + const { ts } = paths(projects.get(first)!); + const messages = collectLogs(); + + appTree.overwrite( + ts, + "import { KBQ_DATEPICKER_CONFIGURATION } from '@koobiq/components/datepicker';\n" + + 'const defaults = inject(KBQ_DATEPICKER_CONFIGURATION);\n' + ); + + await run(first); + + expect(messages.join('\n')).toContain('KBQ_DATEPICKER_CONFIGURATION now supplies the defaults only'); + }); + + it('does not warn about a provider it already rewrote', async () => { + const [first] = projects.keys(); + const { ts } = paths(projects.get(first)!); + const messages = collectLogs(); + + appTree.overwrite( + ts, + "import { KBQ_FILTER_BAR_CONFIGURATION } from '@koobiq/components/filter-bar';\n" + + 'const providers = [{ provide: KBQ_FILTER_BAR_CONFIGURATION, useValue: strings }];\n' + ); + + await run(first); + + expect(messages.join('\n')).not.toContain('KBQ_FILTER_BAR_CONFIGURATION'); + }); + + it('warns about a read of the removed externalConfiguration member', async () => { + const [first] = projects.keys(); + const { ts } = paths(projects.get(first)!); + const messages = collectLogs(); + + appTree.overwrite(ts, 'export class App {\n label = this.nav.externalConfiguration.collapse;\n}\n'); + + await run(first); + + expect(messages.join('\n')).toContain('The externalConfiguration member was removed'); + }); + + it('warns about a write to the now read-only configuration member', async () => { + const [first] = projects.keys(); + const { ts } = paths(projects.get(first)!); + const messages = collectLogs(); + + appTree.overwrite( + ts, + "import { KbqFilterBar } from '@koobiq/components/filter-bar';\n" + + 'export class App {\n' + + ' bar!: KbqFilterBar;\n' + + ' apply() {\n' + + ' this.bar.configuration = strings;\n' + + ' }\n' + + '}\n' + ); + + await run(first); + + expect(messages.join('\n')).toContain('is a read-only getter over a signal'); + }); + + it('does not warn about a .configuration write in a file unrelated to the components', async () => { + const [first] = projects.keys(); + const { ts } = paths(projects.get(first)!); + const messages = collectLogs(); + + appTree.overwrite( + ts, + 'export class App {\n init() {\n this.chart.configuration = {};\n }\n}\n' + ); + + await run(first); + + expect(messages.join('\n')).not.toContain('is a read-only getter over a signal'); + }); + + it('warns about an externalConfiguration read in an external template', async () => { + const [first] = projects.keys(); + const { html } = paths(projects.get(first)!); + const messages = collectLogs(); + + appTree.overwrite( + html, + '\n{{ nav.externalConfiguration.collapse }}\n' + ); + + await run(first); + + expect(messages.join('\n')).toContain('The externalConfiguration member was removed'); + }); + + it('always reports the locale resolution change', async () => { + const [first] = projects.keys(); + const messages = collectLogs(); + + await run(first); + + expect(messages.join('\n')).toContain('Locale resolution order changed'); + }); + }); + + describe('files it must not touch', () => { + it('leaves a provider for an unrelated token alone', async () => { + const [first] = projects.keys(); + const { ts } = paths(projects.get(first)!); + const original = 'const providers = [{ provide: KBQ_SOME_OTHER_CONFIGURATION, useValue: strings }];\n'; + + appTree.overwrite(ts, original); + + expect((await run(first)).readText(ts)).toBe(original); + }); + + it('leaves the token mentioned only in a comment or a string alone', async () => { + const [first] = projects.keys(); + const { ts } = paths(projects.get(first)!); + const original = + '// KBQ_FILTER_BAR_CONFIGURATION used to win over the locale service.\n' + + "const name = 'KBQ_FILTER_BAR_CONFIGURATION';\n"; + const messages = collectLogs(); + + appTree.overwrite(ts, original); + + expect((await run(first)).readText(ts)).toBe(original); + expect(messages.join('\n')).not.toContain('KBQ_FILTER_BAR_CONFIGURATION now supplies the defaults only'); + }); + }); + + describe('ng update entry point', () => { + it('applies the fix when invoked without options', async () => { + const [first] = projects.keys(); + const { ts } = paths(projects.get(first)!); + + appTree.overwrite( + ts, + "import { KBQ_FILTER_BAR_CONFIGURATION } from '@koobiq/components/filter-bar';\n" + + 'const providers = [{ provide: KBQ_FILTER_BAR_CONFIGURATION, useValue: strings }];\n' + ); + + // `ng update` passes no options, and migrations.json declares no schema, so the + // schema default never reaches the rule — it has to default `fix` itself. + const runnerFromMigrations = new SchematicTestRunner('migrations', migrationsPath); + const result = await runnerFromMigrations.runSchematic(SCHEMATIC_NAME, {}, appTree); + + expect(result.readText(ts)).toContain('kbqFilterBarLocaleConfigurationProvider(strings)'); + }); + }); + + describe('dry run', () => { + it('reports without writing when fix is false', async () => { + const [first] = projects.keys(); + const { ts } = paths(projects.get(first)!); + const original = + "import { KBQ_FILTER_BAR_CONFIGURATION } from '@koobiq/components/filter-bar';\n" + + 'const providers = [{ provide: KBQ_FILTER_BAR_CONFIGURATION, useValue: strings }];\n'; + const messages = collectLogs(); + + appTree.overwrite(ts, original); + + const result = await run(first, false); + + expect(result.readText(ts)).toBe(original); + expect(messages.join('\n')).toContain('would update'); + }); + }); + + it('leaves the second project untouched when scoped to the first', async () => { + const [first, second] = projects.keys(); + const { ts: firstTs } = paths(projects.get(first)!); + const { ts: secondTs } = paths(projects.get(second)!); + const original = + "import { KBQ_FILTER_BAR_CONFIGURATION } from '@koobiq/components/filter-bar';\n" + + 'const providers = [{ provide: KBQ_FILTER_BAR_CONFIGURATION, useValue: strings }];\n'; + + appTree.overwrite(firstTs, original); + appTree.overwrite(secondTs, original); + + const result = await run(first); + + expect(result.readText(firstTs)).not.toContain('KBQ_FILTER_BAR_CONFIGURATION'); + expect(result.readText(secondTs)).toBe(original); + }); +}); diff --git a/packages/schematics/src/migrations/locale-configuration-providers/index.ts b/packages/schematics/src/migrations/locale-configuration-providers/index.ts new file mode 100644 index 0000000000..fba088b537 --- /dev/null +++ b/packages/schematics/src/migrations/locale-configuration-providers/index.ts @@ -0,0 +1,381 @@ +import { Path } from '@angular-devkit/core'; +import { Rule, SchematicContext, Tree } from '@angular-devkit/schematics'; +import ts from 'typescript'; +import { logMessage } from '../../utils/messages'; +import { setupOptions } from '../../utils/package-config'; +import { + BEHAVIOUR_NOTE, + COMPONENT_MENTIONS, + leftoverTokenMessage, + memberWarnPatterns, + MIGRATED_PROVIDER_TOKENS, + MigratedProviderToken, + nonArrayProviderMessage, + PROVIDE_PROPERTY, + UNSUPPORTED_PROPERTIES, + unsupportedShapeMessage, + VALUE_PROPERTY, + WarnPattern +} from './data'; +import { Schema } from './schema'; + +const TS_EXT = '.ts'; +const HTML_EXT = '.html'; + +const LABEL = '[locale-configuration-providers]'; + +/** A replacement of the `[start, end)` range of the file content. */ +interface Rewrite { + start: number; + end: number; + text: string; + entry: MigratedProviderToken; +} + +/** A provider the schematic refused to rewrite, reported once per occurrence. */ +interface ProviderWarning { + token: string; + message: string; +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +const createSourceFile = (fileName: string, content: string): ts.SourceFile => + ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + +/** The name of a `{ key: value }` property, covering both `provide:` and `'provide':`. */ +function assignedPropertyName(property: ts.ObjectLiteralElementLike): string | null { + if (!ts.isPropertyAssignment(property)) return null; + if (!ts.isIdentifier(property.name) && !ts.isStringLiteralLike(property.name)) return null; + + return property.name.text; +} + +function findProperty(literal: ts.ObjectLiteralExpression, name: string): ts.PropertyAssignment | undefined { + return literal.properties.find((property) => assignedPropertyName(property) === name) as + ts.PropertyAssignment | undefined; +} + +/** The migrated token a `{ provide: … }` object literal names, if it names one at all. */ +function providedToken(node: ts.Node): MigratedProviderToken | null { + if (!ts.isObjectLiteralExpression(node)) return null; + + const initializer = findProperty(node, PROVIDE_PROPERTY)?.initializer; + + if (!initializer || !ts.isIdentifier(initializer)) return null; + + return MIGRATED_PROVIDER_TOKENS.find(({ token }) => token === initializer.text) ?? null; +} + +/** + * Collects the `{ provide: KBQ__CONFIGURATION, useValue: … }` entries to rewrite, and the + * provider objects that need a human instead. + */ +function scanProviders(sourceFile: ts.SourceFile): { rewrites: Rewrite[]; warnings: ProviderWarning[] } { + const rewrites: Rewrite[] = []; + const warnings: ProviderWarning[] = []; + + const visit = (node: ts.Node) => { + const entry = providedToken(node); + + if (entry) { + const literal = node as ts.ObjectLiteralExpression; + const unsupported = UNSUPPORTED_PROPERTIES.find((name) => findProperty(literal, name)); + const value = findProperty(literal, VALUE_PROPERTY); + + if (unsupported) { + warnings.push({ token: entry.token, message: unsupportedShapeMessage(entry, unsupported) }); + } else if (!ts.isArrayLiteralExpression(literal.parent)) { + // Being an array element is what makes the entry safe to replace. A provider object bound + // to a name (`export const P = { provide: KBQ_…, useValue: … };`) is not an element of + // anything, and the helper returns a `Provider` rather than an object literal — swapping it + // in would silently change what that name means to everything that reads it. + warnings.push({ token: entry.token, message: nonArrayProviderMessage(entry) }); + } else if (value && literal.properties.every((property) => isKnownProperty(property))) { + rewrites.push({ + start: literal.getStart(sourceFile), + end: literal.getEnd(), + text: `${entry.helper}(${sourceFile.text.slice( + value.initializer.getStart(sourceFile), + value.initializer.getEnd() + )})`, + entry + }); + } + // Anything left — no `useValue` at all, a spread, or an extra property such as `multi` that + // the single-argument helper has nowhere to put — keeps the token, so the leftover-reference + // warning reports it. + } + + ts.forEachChild(node, visit); + }; + + ts.forEachChild(sourceFile, visit); + + return { rewrites, warnings }; +} + +function isKnownProperty(property: ts.ObjectLiteralElementLike): boolean { + const name = assignedPropertyName(property); + + return name === PROVIDE_PROPERTY || name === VALUE_PROPERTY; +} + +/** Splices the replacements in right-to-left, so earlier offsets stay valid. */ +function applyRewrites(content: string, rewrites: Rewrite[]): string { + let result = content; + + for (const { start, end, text } of [...rewrites].sort((a, b) => b.start - a.start)) { + result = result.slice(0, start) + text + result.slice(end); + } + + return result; +} + +/** + * Whether `name` is still used outside an import statement. The import clause itself does not count — + * it is exactly what gets dropped once nothing else refers to the token. + */ +function hasNonImportReference(sourceFile: ts.SourceFile, name: string): boolean { + let found = false; + + const visit = (node: ts.Node) => { + if (found || ts.isImportDeclaration(node)) return; + + if (ts.isIdentifier(node) && node.text === name) { + found = true; + + return; + } + + ts.forEachChild(node, visit); + }; + + ts.forEachChild(sourceFile, visit); + + return found; +} + +/** + * Idempotently strips `symbol` from any `import { … } from 'from'` clause. + * - Multi-symbol clause: drop just that symbol, keep the others. + * - Single-symbol clause: drop the whole import line. + */ +function removeImport(content: string, symbol: string, from: string): string { + // The trailing part deliberately stops after a single newline (`[ \t]*\r?\n?` rather than `\s*\n?`): + // dropping the whole import line must not also swallow the blank line that separates the import + // block from the code below it. + const moduleRe = new RegExp( + `(import\\s*(?:type\\s*)?\\{)([^}]*)(\\}\\s*from\\s*['"]${escapeRegExp(from)}['"];?[ \\t]*\\r?\\n?)`, + 'g' + ); + + return content.replace(moduleRe, (full, open: string, body: string, close: string) => { + const items = body + .split(',') + .map((item) => item.trim()) + .filter(Boolean); + + // An already-empty clause is somebody else's import — leave it alone. + if (items.length === 0) return full; + + // Handle ` as ` — match on the source name, not the alias. + const kept = items.filter((spec) => spec.split(/\s+as\s+/)[0].trim() !== symbol); + + if (kept.length === items.length) return full; + if (kept.length === 0) return ''; + + return `${open} ${kept.join(', ')} ${close}`; + }); +} + +/** + * Idempotently adds `import { symbol } from 'from'`: into an existing clause of the same module when + * there is one, otherwise as a new line after the leading comments. + */ +function ensureImport(content: string, symbol: string, from: string): string { + // Already imported from anywhere — a re-export or an alias — so leave it alone. + const alreadyImported = new RegExp( + `import\\s*(?:type\\s*)?\\{[^}]*\\b${escapeRegExp(symbol)}\\b[^}]*\\}\\s*from\\s*['"][^'"]+['"]` + ); + + if (alreadyImported.test(content)) return content; + + // Deliberately matches a value clause only: a file may carry a separate `import type { … }` clause for + // the same module, and splicing a helper that is called as a value into that one is a compile error. + const sameModule = new RegExp(`(import\\s*\\{)([^}]*)(\\}\\s*from\\s*['"]${escapeRegExp(from)}['"])`); + + if (sameModule.test(content)) { + return content.replace(sameModule, (_full, open: string, body: string, close: string) => { + const trimmed = body.trim().replace(/,$/, ''); + const next = trimmed.length === 0 ? ` ${symbol} ` : `${body.replace(/\s*$/, '')}, ${symbol} `; + + return `${open}${next}${close}`; + }); + } + + const lines = content.split('\n'); + let insertAt = 0; + let inBlockComment = false; + + for (let i = 0; i < lines.length; i++) { + const trimmed = lines[i].trim(); + + if (inBlockComment) { + if (trimmed.endsWith('*/')) inBlockComment = false; + + insertAt = i + 1; + continue; + } + + if (trimmed.startsWith('/*')) { + inBlockComment = !trimmed.includes('*/'); + insertAt = i + 1; + continue; + } + + if (trimmed.startsWith('//') || trimmed.length === 0) { + insertAt = i + 1; + continue; + } + + break; + } + + lines.splice(insertAt, 0, `import { ${symbol} } from '${from}';`); + + return lines.join('\n'); +} + +/** The migrated tokens the rewrite pass touched, in declaration order. */ +function rewrittenTokens(rewrites: Rewrite[]): MigratedProviderToken[] { + return MIGRATED_PROVIDER_TOKENS.filter((entry) => rewrites.some(({ entry: touched }) => touched === entry)); +} + +function logWarnings(context: SchematicContext, filePath: string, content: string, patterns: WarnPattern[]) { + const mentionsComponent = COMPONENT_MENTIONS.some((mention) => content.includes(mention)); + + for (const { pattern, message, needsComponentMention } of patterns) { + if (needsComponentMention && !mentionsComponent) continue; + + if (new RegExp(pattern).test(content)) { + logMessage(context.logger, [`${LABEL} ${filePath}`, ` ${message}`]); + } + } +} + +/** + * Reports the tokens still referenced after the rewrite pass — an `inject()` call, a provider shape the + * helper could not take over, a re-export. `alreadyWarned` holds the tokens a more specific message + * already covered, so a single provider is never reported twice. + */ +function logLeftoverTokens( + context: SchematicContext, + filePath: string, + content: string, + alreadyWarned: Set +): void { + const candidates = MIGRATED_PROVIDER_TOKENS.filter( + ({ token }) => !alreadyWarned.has(token) && content.includes(token) + ); + + if (candidates.length === 0) return; + + const sourceFile = createSourceFile(filePath, content); + + for (const entry of candidates) { + if (!hasNonImportReference(sourceFile, entry.token)) continue; + + logMessage(context.logger, [`${LABEL} ${filePath}`, ` ${leftoverTokenMessage(entry)}`]); + } +} + +export default function localeConfigurationProviders(options: Schema): Rule { + return async (tree: Tree, context: SchematicContext) => { + const { project } = options; + // `ng update` invokes migrations with no options at all, and migrations.json declares no schema, + // so the schema default never reaches us — applying the fix is the intended behaviour there. + const fix = options.fix ?? true; + const projectDefinition = await setupOptions(project, tree); + const root = projectDefinition?.root ?? ''; + const rootDir = root ? tree.getDir(root as Path) : tree.root; + const filePaths: Path[] = []; + + rootDir.visit((filePath: Path) => { + if (filePath.includes('node_modules') || filePath.includes('/dist/')) return; + if (!filePath.endsWith(TS_EXT) && !filePath.endsWith(HTML_EXT)) return; + + filePaths.push(filePath); + }); + + let touched = 0; + + for (const filePath of filePaths) { + const originalContent = tree.read(filePath)?.toString(); + + if (!originalContent) continue; + + let content = originalContent; + const providerWarnings: ProviderWarning[] = []; + const isTs = filePath.endsWith(TS_EXT); + + // Parsing every `.ts` of the project is not free, and nothing can change in a file that + // mentions none of the tokens. + if (isTs && MIGRATED_PROVIDER_TOKENS.some(({ token }) => content.includes(token))) { + const { rewrites, warnings } = scanProviders(createSourceFile(filePath, content)); + + providerWarnings.push(...warnings); + content = applyRewrites(content, rewrites); + + if (content !== originalContent) { + // Those edits moved every offset that followed them, and the leftover check has to see + // the rewritten file rather than the positions it started with. + const rewritten = createSourceFile(filePath, content); + + for (const entry of rewrittenTokens(rewrites)) { + // The helper joins an existing clause of the module before the token leaves it, so + // the import keeps its place in the file even when the token was its only symbol. + content = ensureImport(content, entry.helper, entry.from); + + if (!hasNonImportReference(rewritten, entry.token)) { + content = removeImport(content, entry.token, entry.from); + } + } + } + } + + // Warn on what is left over, so an auto-fixed usage does not also produce a "manual migration + // required" note. In dry-run mode the fix is not written, so report against the original. + const reported = fix ? content : originalContent; + + logWarnings(context, filePath, reported, memberWarnPatterns); + + if (isTs) { + for (const { message } of providerWarnings) { + logMessage(context.logger, [`${LABEL} ${filePath}`, ` ${message}`]); + } + + logLeftoverTokens(context, filePath, reported, new Set(providerWarnings.map(({ token }) => token))); + } + + if (content === originalContent) continue; + + touched++; + + if (fix) { + tree.overwrite(filePath, content); + } else { + logMessage(context.logger, [`${LABEL} would update ${filePath} (run with --fix to apply)`]); + } + } + + logMessage(context.logger, [ + `${LABEL} processed tree under "${root || ''}", ` + + `${fix ? 'updated' : 'would update'} ${touched} file(s).`, + '', + ...BEHAVIOUR_NOTE + ]); + }; +} diff --git a/packages/schematics/src/migrations/locale-configuration-providers/schema.json b/packages/schematics/src/migrations/locale-configuration-providers/schema.json new file mode 100644 index 0000000000..410feef80d --- /dev/null +++ b/packages/schematics/src/migrations/locale-configuration-providers/schema.json @@ -0,0 +1,20 @@ +{ + "$schema": "http://json-schema.org/schema", + "$id": "koobiq-components-locale-configuration-providers", + "title": "Koobiq components locale configuration providers", + "type": "object", + "properties": { + "project": { + "type": "string", + "description": "Name of the project to migrate. If omitted, the migration runs over the whole tree.", + "$default": { + "$source": "projectName" + } + }, + "fix": { + "type": "boolean", + "default": true, + "description": "When true, applies all auto-fix replacements. When false, prints what would change without writing." + } + } +} diff --git a/packages/schematics/src/migrations/locale-configuration-providers/schema.ts b/packages/schematics/src/migrations/locale-configuration-providers/schema.ts new file mode 100644 index 0000000000..fefc5cd6b3 --- /dev/null +++ b/packages/schematics/src/migrations/locale-configuration-providers/schema.ts @@ -0,0 +1,6 @@ +export interface Schema { + /** Name of the project to migrate. */ + project?: string; + /** When true, applies replacements; when false, only logs what would change. Defaults to true. */ + fix?: boolean; +} diff --git a/tools/check-public-api-any/baseline.json b/tools/check-public-api-any/baseline.json new file mode 100644 index 0000000000..1d0019e127 --- /dev/null +++ b/tools/check-public-api-any/baseline.json @@ -0,0 +1,56 @@ +{ + "accordion": 5, + "actions-panel": 13, + "alert": 1, + "app-switcher": 3, + "autocomplete": 9, + "badge": 1, + "breadcrumbs": 14, + "button-toggle": 12, + "checkbox": 7, + "clamped-text": 3, + "code-block": 9, + "content-panel": 7, + "core": 62, + "datepicker": 12, + "dropdown": 7, + "dynamic-translation": 1, + "ellipsis-center": 1, + "file-upload": 11, + "filter-bar": 34, + "flag": 2, + "form-field": 17, + "icon": 2, + "inline-edit": 7, + "input": 12, + "link": 7, + "list": 16, + "loader-overlay": 1, + "modal": 22, + "navbar": 7, + "notification-center": 10, + "overflow-items": 7, + "popover": 12, + "radio": 13, + "scrollbar": 1, + "scrollbar-deprecated": 2, + "search-expandable": 1, + "select": 30, + "sidepanel": 17, + "skeleton": 1, + "table": 2, + "tabs": 17, + "tags": 19, + "textarea": 3, + "time-range": 5, + "timepicker": 4, + "timezone": 1, + "title": 1, + "toast": 15, + "toggle": 6, + "tooltip": 8, + "top-bar": 1, + "tree": 44, + "tree-select": 25, + "username": 5 +} diff --git a/tools/check-public-api-any/index.ts b/tools/check-public-api-any/index.ts new file mode 100644 index 0000000000..696914ece8 --- /dev/null +++ b/tools/check-public-api-any/index.ts @@ -0,0 +1,86 @@ +/** + * Ratchet on the amount of `any` / `unknown` in the published type surface. + * + * The library compiles with `noImplicitAny` off and lints with `@typescript-eslint/no-explicit-any` + * disabled, so nothing stops an untyped member from reaching consumers — `KbqLocaleService` shipped + * `getParams(componentName: string): any` for years that way. This tool does not forbid `any`; it fixes + * the current amount per package and fails when it grows, so that the ongoing cleanup cannot be silently + * undone by the next feature. + * + * Only hand-written declarations are counted. Angular's own emitted members (`ɵfac`, `ɵdir`, `ɵcmp`, + * `ngAcceptInputType_*`) carry `any` that no author can remove, and counting them would drown the signal. + * + * Run `yarn run check-public-api-any` to verify, `yarn run approve-public-api-any` to record the new + * counts after a cleanup. The golden files it reads are produced by `yarn run approve-api`. + */ + +import { readdirSync, readFileSync, writeFileSync } from 'fs'; +import { join } from 'path'; + +const projectRoot = join(__dirname, '..', '..'); +const goldenDir = join(projectRoot, 'tools', 'public_api_guard', 'components'); +const baselinePath = join(__dirname, 'baseline.json'); + +const approve = process.argv.includes('--approve'); + +/** Members Angular generates into the `.d.ts`; their `any` is not the author's to remove. */ +const isGenerated = (line: string): boolean => /ɵ|ngAcceptInputType_/.test(line); + +const isComment = (line: string): boolean => /^\s*(\/\/|\*|\/\*)/.test(line); + +const countUntyped = (source: string): number => + source + .split('\n') + .filter((line) => !isGenerated(line) && !isComment(line)) + .reduce((total, line) => total + (line.match(/\b(any|unknown)\b/g) ?? []).length, 0); + +const collect = (): Record => + Object.fromEntries( + readdirSync(goldenDir) + .filter((file) => file.endsWith('.api.md')) + .map((file): [string, number] => [ + file.replace('.api.md', ''), + countUntyped(readFileSync(join(goldenDir, file), 'utf8')) + ]) + .filter(([, count]) => count > 0) + .sort(([a], [b]) => a.localeCompare(b)) + ); + +const current = collect(); + +if (approve) { + writeFileSync(baselinePath, `${JSON.stringify(current, null, 4)}\n`); + + const total = Object.values(current).reduce((sum, count) => sum + count, 0); + + console.log(`✅ Recorded ${total} untyped members across ${Object.keys(current).length} packages.`); + process.exit(0); +} + +const baseline: Record = JSON.parse(readFileSync(baselinePath, 'utf8')); +const packages = [...new Set([...Object.keys(baseline), ...Object.keys(current)])].sort(); + +const grown = packages.filter((name) => (current[name] ?? 0) > (baseline[name] ?? 0)); +const shrunk = packages.filter((name) => (current[name] ?? 0) < (baseline[name] ?? 0)); + +if (grown.length > 0) { + console.error('\n❌ The published type surface gained `any` / `unknown`:\n'); + grown.forEach((name) => console.error(` - ${name}: ${baseline[name] ?? 0} → ${current[name] ?? 0}`)); + console.error( + '\nNarrow the new members instead. The safe direction is to narrow returns and fields, widen\n' + + "parameters, and never narrow a parameter; where the type is the consumer's to choose, use a\n" + + 'generic with an `any` default rather than a concrete type.\n' + ); + process.exit(1); +} + +if (shrunk.length > 0) { + console.error('\n❌ The recorded counts are stale — the surface improved:\n'); + shrunk.forEach((name) => console.error(` - ${name}: ${baseline[name] ?? 0} → ${current[name] ?? 0}`)); + console.error('\nLock the improvement in with `yarn run approve-public-api-any`.\n'); + process.exit(1); +} + +const total = Object.values(current).reduce((sum, count) => sum + count, 0); + +console.log(`✅ No new untyped members. ${total} remain across ${Object.keys(current).length} packages.`); diff --git a/tools/check-public-api-any/tsconfig.json b/tools/check-public-api-any/tsconfig.json new file mode 100644 index 0000000000..2d4a3a84ec --- /dev/null +++ b/tools/check-public-api-any/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "strict": true, + "target": "es2020", + "module": "commonjs", + "esModuleInterop": true, + "resolveJsonModule": true, + "noEmit": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["**/*.ts"] +} diff --git a/tools/public_api_guard/components/actions-panel.api.md b/tools/public_api_guard/components/actions-panel.api.md index fbcb318239..4987d30808 100644 --- a/tools/public_api_guard/components/actions-panel.api.md +++ b/tools/public_api_guard/components/actions-panel.api.md @@ -15,6 +15,7 @@ import * as i0 from '@angular/core'; import { InjectionToken } from '@angular/core'; import { Injector } from '@angular/core'; import { KbqActionsPanelLocaleConfiguration } from '@koobiq/components/core'; +import { KbqDeepPartial } from '@koobiq/components/core'; import { Observable } from 'rxjs'; import { OnDestroy } from '@angular/core'; import { Overlay } from '@angular/cdk/overlay'; @@ -72,7 +73,7 @@ export class KbqActionsPanelContainer extends CdkDialogContainer implements OnDe protected readonly config: KbqActionsPanelConfig; protected _contentAttached(): void; protected handleEscape(event: KeyboardEvent): void; - protected readonly localeConfiguration: i0.Signal; + protected readonly localeConfiguration: i0.Signal; // (undocumented) ngOnDestroy(): void; protected onAnimationDone(event: AnimationEvent_2): void; @@ -89,7 +90,7 @@ export class KbqActionsPanelContainer extends CdkDialogContainer implements OnDe export const kbqActionsPanelDefaultConfigProvider: (config: KbqActionsPanelConfig) => Provider; // @public -export const kbqActionsPanelLocaleConfigurationProvider: (configuration: KbqActionsPanelLocaleConfiguration) => Provider; +export const kbqActionsPanelLocaleConfigurationProvider: (configuration: KbqDeepPartial) => Provider; // @public (undocumented) export class KbqActionsPanelModule { diff --git a/tools/public_api_guard/components/app-switcher.api.md b/tools/public_api_guard/components/app-switcher.api.md index 674f7c14c5..ae99b8355d 100644 --- a/tools/public_api_guard/components/app-switcher.api.md +++ b/tools/public_api_guard/components/app-switcher.api.md @@ -14,7 +14,8 @@ import { FocusKeyManager } from '@koobiq/components/core'; import { FormControl } from '@angular/forms'; import * as i0 from '@angular/core'; import { InjectionToken } from '@angular/core'; -import { KbqAppSwitcherConfiguration } from '@koobiq/components/core'; +import { KbqAppSwitcherLocaleConfiguration } from '@koobiq/components/core'; +import { KbqDeepPartial } from '@koobiq/components/core'; import { KbqDropdown } from '@koobiq/components/dropdown'; import { KbqDropdownItem } from '@koobiq/components/dropdown'; import { KbqInput } from '@koobiq/components/input'; @@ -22,7 +23,6 @@ import { KbqPopUp } from '@koobiq/components/core'; import { KbqPopUpPlacementValues } from '@koobiq/components/core'; import { KbqPopUpSizeValues } from '@koobiq/components/core'; import { KbqPopUpTrigger } from '@koobiq/components/core'; -import * as _koobiq_components_core from '@koobiq/components/core'; import { OnDestroy } from '@angular/core'; import { OnInit } from '@angular/core'; import { Overlay } from '@angular/cdk/overlay'; @@ -39,10 +39,10 @@ import { Type } from '@angular/core'; export function defaultGroupBy(app: KbqAppSwitcherApp, groups: Record, untyped: KbqAppSwitcherApp[]): void; // @public -export const KBQ_APP_SWITCHER_CONFIGURATION: InjectionToken; +export const KBQ_APP_SWITCHER_CONFIGURATION: InjectionToken; // @public -export const KBQ_APP_SWITCHER_DEFAULT_CONFIGURATION: KbqAppSwitcherConfiguration; +export const KBQ_APP_SWITCHER_DEFAULT_CONFIGURATION: KbqAppSwitcherLocaleConfiguration; // @public export const KBQ_APP_SWITCHER_SCROLL_STRATEGY: InjectionToken<() => ScrollStrategy>; @@ -89,17 +89,15 @@ export class KbqAppSwitcherComponent extends KbqPopUp implements AfterViewInit, protected activeApp: KbqAppSwitcherApp | undefined; protected activeSite: KbqAppSwitcherSite | undefined; protected allItems: QueryList; - configuration: KbqAppSwitcherConfiguration; + get configuration(): KbqAppSwitcherLocaleConfiguration; escapeHandler(): void; - readonly externalConfiguration: KbqAppSwitcherConfiguration | null; filteredSites: KbqAppSwitcherSite[]; protected focusinHandler(event: FocusEvent): void; protected focusoutHandler(event: FocusEvent): void; readonly input: i0.Signal; protected keydownHandler(event: KeyboardEvent): void; protected keyManager: FocusKeyManager; - get localeData(): KbqAppSwitcherConfiguration; - protected readonly localeService: _koobiq_components_core.KbqLocaleService | null; + get localeData(): KbqAppSwitcherLocaleConfiguration; protected readonly nestedAliasClass = "kbq-app-switcher-site_nested"; // (undocumented) ngAfterViewInit(): void; @@ -163,6 +161,9 @@ export class KbqAppSwitcherListItem extends KbqDropdownItem { static ɵfac: i0.ɵɵFactoryDeclaration; } +// @public +export const kbqAppSwitcherLocaleConfigurationProvider: (configuration: KbqDeepPartial) => Provider; + // @public (undocumented) export class KbqAppSwitcherModule { // (undocumented) diff --git a/tools/public_api_guard/components/clamped-text.api.md b/tools/public_api_guard/components/clamped-text.api.md index 9d78e66a9f..7aff14189c 100644 --- a/tools/public_api_guard/components/clamped-text.api.md +++ b/tools/public_api_guard/components/clamped-text.api.md @@ -8,20 +8,21 @@ import { AfterViewInit } from '@angular/core'; import * as _angular_core from '@angular/core'; import { ElementRef } from '@angular/core'; import { InjectionToken } from '@angular/core'; -import { KbqClampedTextLocaleConfig } from '@koobiq/components/core'; +import { KbqClampedTextLocaleConfiguration } from '@koobiq/components/core'; +import { KbqDeepPartial } from '@koobiq/components/core'; import * as _koobiq_components_core from '@koobiq/components/core'; import { OnInit } from '@angular/core'; import { Provider } from '@angular/core'; import { Signal } from '@angular/core'; // @public -export const KBQ_CLAMPED_TEXT_LOCALE_CONFIGURATION: InjectionToken; +export const KBQ_CLAMPED_TEXT_LOCALE_CONFIGURATION: InjectionToken; // @public (undocumented) export interface KbqClamped { hasToggle: Signal; isCollapsed: Signal; - localeConfiguration: Signal; + localeConfiguration: Signal; toggle(event: Event): void; } @@ -33,7 +34,7 @@ export class KbqClampedList implements KbqClamped { readonly hiddenThreshold: _angular_core.InputSignalWithTransform; readonly isCollapsed: _angular_core.ModelSignal; readonly items: _angular_core.InputSignal; - readonly localeConfiguration: _angular_core.Signal<_koobiq_components_core.KbqClampedTextLocaleConfig>; + readonly localeConfiguration: _angular_core.Signal<_koobiq_components_core.KbqClampedTextLocaleConfiguration>; readonly showMoreCountText: _angular_core.Signal; toggle(event: Event): void; readonly visibleItems: _angular_core.Signal; @@ -66,7 +67,7 @@ export class KbqClampedText implements KbqClamped, OnInit, AfterViewInit { readonly isCollapsedChange: _angular_core.OutputEmitterRef; protected readonly isToggleCollapsed: _angular_core.WritableSignal; protected readonly lineClamp: _angular_core.WritableSignal; - readonly localeConfiguration: _angular_core.Signal<_koobiq_components_core.KbqClampedTextLocaleConfig>; + readonly localeConfiguration: _angular_core.Signal<_koobiq_components_core.KbqClampedTextLocaleConfiguration>; // (undocumented) ngAfterViewInit(): void; // (undocumented) @@ -85,7 +86,7 @@ export class KbqClampedText implements KbqClamped, OnInit, AfterViewInit { export const kbqClampedTextDefaultMaxRows = 5; // @public -export const kbqClampedTextLocaleConfigurationProvider: (configuration: KbqClampedTextLocaleConfig) => Provider; +export const kbqClampedTextLocaleConfigurationProvider: (configuration: KbqDeepPartial) => Provider; // @public (undocumented) export class KbqClampedTextModule { @@ -98,7 +99,10 @@ export class KbqClampedTextModule { } // @public -export function kbqInjectKbqClampedLocaleConfiguration(): Signal; +export function kbqInjectClampedTextLocaleConfiguration(): Signal; + +// @public @deprecated (undocumented) +export const kbqInjectKbqClampedLocaleConfiguration: typeof kbqInjectClampedTextLocaleConfiguration; // (No @packageDocumentation comment for this package) diff --git a/tools/public_api_guard/components/code-block.api.md b/tools/public_api_guard/components/code-block.api.md index 05fb0eca39..f738c08798 100644 --- a/tools/public_api_guard/components/code-block.api.md +++ b/tools/public_api_guard/components/code-block.api.md @@ -13,6 +13,7 @@ import { InjectionToken } from '@angular/core'; import { KbqButtonStyles } from '@koobiq/components/button'; import { KbqCodeBlockLocaleConfiguration } from '@koobiq/components/core'; import { KbqComponentColors } from '@koobiq/components/core'; +import { KbqDeepPartial } from '@koobiq/components/core'; import { LanguageFn } from 'highlight.js'; import { Provider } from '@angular/core'; import { TemplateRef } from '@angular/core'; @@ -154,7 +155,7 @@ export type KbqCodeBlockHighlightJsConfig = Partial<{ export const kbqCodeBlockHighlightJsConfigProvider: (options: KbqCodeBlockHighlightJsConfig) => Provider; // @public -export const kbqCodeBlockLocaleConfigurationProvider: (configuration: KbqCodeBlockLocaleConfiguration) => Provider; +export const kbqCodeBlockLocaleConfigurationProvider: (configuration: KbqDeepPartial) => Provider; // @public (undocumented) export class KbqCodeBlockModule { diff --git a/tools/public_api_guard/components/core.api.md b/tools/public_api_guard/components/core.api.md index 59ec202267..583d5ca7bf 100644 --- a/tools/public_api_guard/components/core.api.md +++ b/tools/public_api_guard/components/core.api.md @@ -685,7 +685,7 @@ export const esLAFormattersData: { }; }; sizeUnits: { - defaultUnitSystem: string; + defaultUnitSystem: "SI"; defaultPrecision: number; unitSystems: { SI: { @@ -1436,7 +1436,7 @@ export function KBQ_DEFAULT_LOCALE_DATA_FACTORY(): { }; }; sizeUnits: { - defaultUnitSystem: string; + defaultUnitSystem: "SI"; defaultPrecision: number; unitSystems: { SI: { @@ -1671,7 +1671,7 @@ export function KBQ_DEFAULT_LOCALE_DATA_FACTORY(): { }; }; sizeUnits: { - defaultUnitSystem: string; + defaultUnitSystem: "SI"; defaultPrecision: number; unitSystems: { SI: { @@ -1911,7 +1911,7 @@ export function KBQ_DEFAULT_LOCALE_DATA_FACTORY(): { }; }; sizeUnits: { - defaultUnitSystem: string; + defaultUnitSystem: "SI"; defaultPrecision: number; unitSystems: { SI: { @@ -2148,7 +2148,7 @@ export function KBQ_DEFAULT_LOCALE_DATA_FACTORY(): { }; }; sizeUnits: { - defaultUnitSystem: string; + defaultUnitSystem: "SI"; defaultPrecision: number; unitSystems: { SI: { @@ -2381,11 +2381,14 @@ export const KBQ_FORM_FIELD_REF: InjectionToken; // @public (undocumented) export const KBQ_INVALID_VALUE_ERROR = "Argument \"value\" must be a finite number!"; +// @public +export const KBQ_LOCALE_CONFIGURATION_OVERRIDES: InjectionToken; + // @public (undocumented) -export const KBQ_LOCALE_DATA: InjectionToken; +export const KBQ_LOCALE_DATA: InjectionToken; // @public (undocumented) -export const KBQ_LOCALE_ID: InjectionToken; +export const KBQ_LOCALE_ID: InjectionToken; // @public (undocumented) export const KBQ_LOCALE_SERVICE: InjectionToken; @@ -2419,6 +2422,12 @@ export const KBQ_PARENT_ANIMATION_COMPONENT: InjectionToken; // @public export const KBQ_PARENT_POPUP: InjectionToken; +// @public +export const KBQ_SELECT_DEFAULT_LOCALE_CONFIGURATION: KbqSelectLocaleConfiguration; + +// @public +export const KBQ_SELECT_LOCALE_CONFIGURATION: InjectionToken; + // @public export const KBQ_SELECT_SCROLL_STRATEGY: InjectionToken<() => ScrollStrategy>; @@ -2472,7 +2481,7 @@ export type KbqA11yLocaleConfiguration = { }; // @public -export const kbqA11yLocaleConfigurationProvider: (configuration: KbqA11yLocaleConfiguration) => Provider; +export const kbqA11yLocaleConfigurationProvider: (configuration: KbqDeepPartial) => Provider; // @public (undocumented) export class KbqAbsoluteLongDatePipe extends BaseLocaleAwareFormatterPipe implements PipeTransform { @@ -2594,8 +2603,11 @@ export enum KbqAnimationDurations { Rapid = "100ms" } +// @public @deprecated (undocumented) +export type KbqAppSwitcherConfiguration = KbqAppSwitcherLocaleConfiguration; + // @public -export type KbqAppSwitcherConfiguration = { +export type KbqAppSwitcherLocaleConfiguration = { searchPlaceholder: string; searchEmptyResult: string; sitesHeader: string; @@ -2626,8 +2638,11 @@ export interface KbqAutoHideScrollStrategyHooks { onHide?: () => void; } +// @public @deprecated (undocumented) +export type KbqBaseFileUploadLocaleConfig = KbqBaseFileUploadLocaleConfiguration; + // @public (undocumented) -export interface KbqBaseFileUploadLocaleConfig { +export interface KbqBaseFileUploadLocaleConfiguration { // (undocumented) browseLink: string; // (undocumented) @@ -2685,8 +2700,11 @@ export interface KbqCheckableClickResult { // @public export type KbqCheckedState = 'true' | 'false' | 'mixed'; +// @public @deprecated (undocumented) +export type KbqClampedTextLocaleConfig = KbqClampedTextLocaleConfiguration; + // @public -export type KbqClampedTextLocaleConfig = { +export type KbqClampedTextLocaleConfiguration = { openText: string; closeText: string; showMoreText: string; @@ -2764,6 +2782,12 @@ export class KbqDataSizePipe implements PipeTransform { // @public (undocumented) export type KbqDateFormats = DateFormats; +// @public +export type KbqDatepickerLocaleConfiguration = { + placeholder: string; + dateInput?: string; +}; + // @public (undocumented) export class KbqDecimalPipe implements KbqNumericPipe, PipeTransform { constructor(); @@ -2779,6 +2803,14 @@ export class KbqDecimalPipe implements KbqNumericPipe, PipeTransform { static ɵprov: i0.ɵɵInjectableDeclaration; } +// @public +export const kbqDeepMerge: (base: T, patch: NoInfer> | undefined) => T; + +// @public +export type KbqDeepPartial = T extends (...args: never[]) => unknown ? T : T extends readonly unknown[] ? T : T extends object ? { + [K in keyof T]?: KbqDeepPartial; +} : T; + // @public export type KbqDefaultSizes = 'compact' | 'normal' | 'big'; @@ -2858,10 +2890,66 @@ export const kbqFilesizeFormatterConfigurationProvider: (configuration: Partial< // @public export type KbqFileTypeSpecifier = Parameters[0]; +// @public @deprecated (undocumented) +export type KbqFileUploadLocaleConfig = KbqFileUploadLocaleConfiguration; + // @public (undocumented) -export type KbqFileUploadLocaleConfig = { - single: KbqBaseFileUploadLocaleConfig; - multiple: KbqMultipleFileUploadLocaleConfig; +export type KbqFileUploadLocaleConfiguration = { + single: KbqBaseFileUploadLocaleConfiguration; + multiple: KbqMultipleFileUploadLocaleConfiguration; +}; + +// @public +export type KbqFilterBarLocaleConfiguration = { + reset: { + buttonName: string; + }; + search: { + tooltip: string; + placeholder: string; + }; + filters: { + defaultName: string; + saveNewFilterTooltip: string; + searchPlaceholder: string; + searchEmptyResult: string; + saveAsNewFilter: string; + saveChanges: string; + saveAsNew: string; + change: string; + resetChanges: string; + remove: string; + name: string; + error: string; + errorHint: string; + saveButton: string; + cancelButton: string; + actionsTooltip: string; + }; + add: { + tooltip: string; + addedAnnouncement: string; + }; + refresher: { + refresh: string; + settings: string; + }; + pipe: { + clearButtonTooltip: string; + removeButtonTooltip: string; + applyButton: string; + emptySearchResult: string; + selectAll: string; + }; + datePipe: { + customPeriod: string; + customPeriodFrom: string; + customPeriodTo: string; + customPeriodErrorHint: string; + customPeriodMinIntervalErrorHint: string; + customPeriodMaxIntervalErrorHint: string; + backToPeriodSelection: string; + }; }; // @public @@ -3006,9 +3094,17 @@ export class KbqHover { // @public export function kbqInjectA11yLocaleConfiguration(): Signal; +// @public +export function kbqInjectLocaleConfiguration(section: K, token: InjectionToken): Signal; + // @public export const kbqInjectNativeElement: () => T; +// @public +export type KbqInputLocaleConfiguration = { + number: KbqNumberInputLocaleConfiguration; +}; + // @public export class KbqLine { // (undocumented) @@ -3022,23 +3118,71 @@ export class KbqLineSetter { constructor(_lines: QueryList, _element: ElementRef); } -// @public (undocumented) -export class KbqLocaleService { - constructor(); - // (undocumented) - addLocale(id: string, localeData: any): void; +// @public +export const kbqLocaleConfigurationOverrideProvider: (section: K, configuration: KbqDeepPartial) => Provider; + +// @public +export interface KbqLocaleData extends KbqLocaleStringsData, KbqLocaleFormattersData { +} + +// @public +export interface KbqLocaleDataInput { // (undocumented) - readonly changes: BehaviorSubject; + [localeId: string]: KbqPartialLocaleData | KbqLocaleItem[] | undefined; // (undocumented) - current: any; + items?: KbqLocaleItem[]; +} + +// @public +export type KbqLocaleDataMap = Record & { + items: KbqLocaleItem[]; +}; + +// @public +export interface KbqLocaleFormattersData { // (undocumented) - getParams(componentName: string): any; + formatters: KbqNumberFormattersLocaleConfiguration; // (undocumented) - id: string; + input: KbqInputLocaleConfiguration; // (undocumented) - readonly locales: any; + sizeUnits: KbqSizeUnitsConfig; +} + +// @public +export type KbqLocaleId = 'en-US' | 'es-LA' | 'pt-BR' | 'ru-RU' | 'tk-TM'; + +// @public +export type KbqLocaleIdLike = KbqLocaleId | (string & {}); + +// @public +export type KbqLocaleItem = { + id: KbqLocaleIdLike; + name: string; +}; + +// @public +export type KbqLocaleSection = keyof KbqLocaleData; + +// @public (undocumented) +export class KbqLocaleService { + constructor(); + addLocale(id: KbqLocaleIdLike, localeData: KbqPartialLocaleData): void; + readonly changes: BehaviorSubject; + // @deprecated (undocumented) + get current(): KbqLocaleData; + set current(value: KbqLocaleData); + readonly data: Signal; + getParams(section: K): KbqLocaleData[K]; // (undocumented) - setLocale(id: string): void; + getParams(section: string): any; + // @deprecated (undocumented) + get id(): string; + set id(value: string); + readonly items: Signal; + readonly localeId: Signal; + readonly locales: KbqLocaleDataMap; + params(section: K): Signal; + setLocale(id: KbqLocaleIdLike): void; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration; // (undocumented) @@ -3058,6 +3202,42 @@ export class KbqLocaleServiceModule { static ɵmod: i0.ɵɵNgModuleDeclaration; } +// @public +export interface KbqLocaleStringsData { + // (undocumented) + a11y: KbqA11yLocaleConfiguration; + // (undocumented) + actionsPanel: KbqActionsPanelLocaleConfiguration; + // (undocumented) + appSwitcher: KbqAppSwitcherLocaleConfiguration; + // (undocumented) + clampedText: KbqClampedTextLocaleConfiguration; + // (undocumented) + codeBlock: KbqCodeBlockLocaleConfiguration; + // (undocumented) + datepicker: KbqDatepickerLocaleConfiguration; + // (undocumented) + fileUpload: KbqFileUploadLocaleConfiguration; + // (undocumented) + filterBar: KbqFilterBarLocaleConfiguration; + // (undocumented) + navbar: KbqNavbarLocaleConfiguration; + // (undocumented) + navbarIc: KbqNavbarIcLocaleConfiguration; + // (undocumented) + notificationCenter: KbqNotificationCenterLocaleConfiguration; + // (undocumented) + searchExpandable: KbqSearchExpandableLocaleConfiguration; + // (undocumented) + select: KbqSelectLocaleConfiguration; + // (undocumented) + timepicker: KbqTimepickerLocaleConfiguration; + // (undocumented) + timeRange: KbqTimeRangeLocaleConfiguration; + // (undocumented) + timezone: KbqTimezoneLocaleConfiguration; +} + // @public export enum KbqMeasurementSystem { // (undocumented) @@ -3084,8 +3264,11 @@ export class KbqMeasureScrollbarService { static ɵprov: i0.ɵɵInjectableDeclaration; } +// @public @deprecated (undocumented) +export type KbqMultipleFileUploadLocaleConfig = KbqMultipleFileUploadLocaleConfiguration; + // @public (undocumented) -export interface KbqMultipleFileUploadLocaleConfig extends KbqBaseFileUploadLocaleConfig { +export interface KbqMultipleFileUploadLocaleConfiguration extends KbqBaseFileUploadLocaleConfiguration { // (undocumented) captionTextForCompactSize: string; // (undocumented) @@ -3094,6 +3277,22 @@ export interface KbqMultipleFileUploadLocaleConfig extends KbqBaseFileUploadLoca title: string; } +// @public +export type KbqNavbarIcLocaleConfiguration = { + toggle: { + pinButton: string; + collapseButton: string; + }; +}; + +// @public +export type KbqNavbarLocaleConfiguration = { + toggle: { + expand: string; + collapse: string; + }; +}; + // @public (undocumented) export class KbqNormalizeWhitespace { protected readonly elementRef: ElementRef; @@ -3104,20 +3303,46 @@ export class KbqNormalizeWhitespace { static ɵfac: i0.ɵɵFactoryDeclaration; } +// @public +export type KbqNotificationCenterLocaleConfiguration = { + notifications: string; + remove: string; + doNotDisturb: string; + showPopUpNotifications: string; + noNotifications: string; + failedToLoadNotifications: string; + repeat: string; + loadingMore: string; +}; + // @public export type KbqNumberFormatOptions = { viewGroupSeparator?: string; }; // @public -export type KbqNumberInputLocaleConfig = { +export type KbqNumberFormattersLocaleConfiguration = { + number: { + rounding: KbqNumberRoundingLocaleConfiguration; + decimal?: KbqNumberFormatOptions; + }; +}; + +// @public @deprecated (undocumented) +export type KbqNumberInputLocaleConfig = KbqNumberInputLocaleConfiguration; + +// @public +export type KbqNumberInputLocaleConfiguration = { groupSeparator: string[]; fractionSeparator: string; startFormattingFrom?: number; } & KbqNumberFormatOptions; +// @public @deprecated (undocumented) +export type KbqNumberRoundingLocaleConfig = KbqNumberRoundingLocaleConfiguration; + // @public -export type KbqNumberRoundingLocaleConfig = { +export type KbqNumberRoundingLocaleConfiguration = { separator: string; groupSeparator: string; thousand: string; @@ -3372,6 +3597,9 @@ export interface KbqParentPopup { closedStream: Observable; } +// @public +export type KbqPartialLocaleData = KbqDeepPartial; + // @public (undocumented) export abstract class KbqPopUp implements OnDestroy { // (undocumented) @@ -3736,6 +3964,12 @@ export class KbqRoundDecimalPipe implements PipeTransform { static ɵprov: i0.ɵɵInjectableDeclaration; } +// @public +export type KbqSearchExpandableLocaleConfiguration = { + tooltip: string; + placeholder: string; +}; + // @public export interface KbqSelectAllAdapter { isSelectable: (item: T) => boolean; @@ -3775,6 +4009,9 @@ export type KbqSelectLocaleConfiguration = { selectAll: string; }; +// @public +export const kbqSelectLocaleConfigurationProvider: (configuration: KbqDeepPartial) => Provider; + // @public (undocumented) export class KbqSelectMatcher { // (undocumented) @@ -4001,7 +4238,18 @@ export interface KbqThemeStore { } // @public -export type KbqTimeRangeLocaleConfig = { +export type KbqTimepickerLocaleConfiguration = { + placeholder: { + full: string; + short: string; + }; +}; + +// @public @deprecated (undocumented) +export type KbqTimeRangeLocaleConfig = KbqTimeRangeLocaleConfiguration; + +// @public +export type KbqTimeRangeLocaleConfiguration = { title: { for: string; placeholder: string; @@ -4025,6 +4273,11 @@ export type KbqTimeRangeLocaleConfig = { }; }; +// @public +export type KbqTimezoneLocaleConfiguration = { + searchPlaceholder: string; +}; + // @public (undocumented) export interface KbqTitleTextRef { // (undocumented) @@ -4208,7 +4461,7 @@ export const N = 78; export const NINE = 57; // @public -export function normalizeNumber(value: string | null | undefined, customConfig: Pick): string; +export function normalizeNumber(value: string | null | undefined, customConfig: Pick): string; // @public (undocumented) export const NUM_CENTER = 12; @@ -4220,7 +4473,7 @@ export const NUM_LOCK = 144; export const NUMBER_FORMAT_REGEXP: RegExp; // @public -export function numberByParts(value: string, customConfig: Pick): { +export function numberByParts(value: string, customConfig: Pick): { integer: string; fraction: string; }; @@ -4424,7 +4677,7 @@ export const ptBRFormattersData: { }; }; sizeUnits: { - defaultUnitSystem: string; + defaultUnitSystem: "SI"; defaultPrecision: number; unitSystems: { SI: { @@ -4874,7 +5127,7 @@ export const ruRUFormattersData: { }; }; sizeUnits: { - defaultUnitSystem: string; + defaultUnitSystem: "SI"; defaultPrecision: number; unitSystems: { SI: { @@ -5235,7 +5488,7 @@ export const tkTMFormattersData: { }; }; sizeUnits: { - defaultUnitSystem: string; + defaultUnitSystem: "SI"; defaultPrecision: number; unitSystems: { SI: { diff --git a/tools/public_api_guard/components/datepicker.api.md b/tools/public_api_guard/components/datepicker.api.md index 96709b0e76..6be85e8f0b 100644 --- a/tools/public_api_guard/components/datepicker.api.md +++ b/tools/public_api_guard/components/datepicker.api.md @@ -23,6 +23,8 @@ import * as i5 from '@koobiq/components/select'; import * as i6 from '@koobiq/components/icon'; import * as i8 from '@angular/common'; import { InjectionToken } from '@angular/core'; +import { KbqDatepickerLocaleConfiguration } from '@koobiq/components/core'; +import { KbqDeepPartial } from '@koobiq/components/core'; import { KbqFormFieldControl } from '@koobiq/components/form-field'; import { KbqSiblingPopup } from '@koobiq/components/core'; import { KbqTooltipTrigger } from '@koobiq/components/tooltip'; @@ -33,6 +35,7 @@ import { OnChanges } from '@angular/core'; import { OnDestroy } from '@angular/core'; import { Overlay } from '@angular/cdk/overlay'; import { OverlayRef } from '@angular/cdk/overlay'; +import { Provider } from '@angular/core'; import { ScrollStrategy } from '@angular/cdk/overlay'; import { SimpleChanges } from '@angular/core'; import { Subject } from 'rxjs'; @@ -40,7 +43,7 @@ import { ValidationErrors } from '@angular/forms'; import { Validator } from '@angular/forms'; // @public -export const KBQ_DATEPICKER_CONFIGURATION: InjectionToken; +export const KBQ_DATEPICKER_CONFIGURATION: InjectionToken; // @public export const KBQ_DATEPICKER_DEFAULT_CONFIGURATION: { @@ -280,7 +283,7 @@ export class KbqDatepickerInput implements KbqFormFieldControl, ControlVal // (undocumented) calendar: KbqCalendar; // (undocumented) - protected configuration: any; + protected get configuration(): KbqDatepickerLocaleConfiguration; // (undocumented) controlType: string; readonly dateChange: _angular_core.OutputEmitterRef>; @@ -303,7 +306,6 @@ export class KbqDatepickerInput implements KbqFormFieldControl, ControlVal set errorState(value: boolean); get errorStateMatcher(): ErrorStateMatcher; set errorStateMatcher(value: ErrorStateMatcher); - protected readonly externalConfiguration: unknown; // (undocumented) focus(): void; // (undocumented) @@ -324,7 +326,6 @@ export class KbqDatepickerInput implements KbqFormFieldControl, ControlVal set kbqDatepickerFilter(value: (date: D | null) => boolean); // (undocumented) set kbqValidationTooltip(tooltip: KbqTooltipTrigger); - protected readonly localeService: _koobiq_components_core.KbqLocaleService | null; get max(): D | null; set max(value: D | null); get min(): D | null; @@ -413,6 +414,9 @@ export class KbqDatepickerIntl { static ɵprov: _angular_core.ɵɵInjectableDeclaration; } +// @public +export const kbqDatepickerLocaleConfigurationProvider: (configuration: KbqDeepPartial) => Provider; + // @public (undocumented) export class KbqDatepickerModule { // (undocumented) diff --git a/tools/public_api_guard/components/file-upload.api.md b/tools/public_api_guard/components/file-upload.api.md index 339657caf7..51f2ccf8b9 100644 --- a/tools/public_api_guard/components/file-upload.api.md +++ b/tools/public_api_guard/components/file-upload.api.md @@ -64,7 +64,7 @@ export const KBQ_DROPZONE_DATA: InjectionToken>; // @public (undocumented) -export const KBQ_FILE_UPLOAD_CONFIGURATION: InjectionToken; +export const KBQ_FILE_UPLOAD_CONFIGURATION: InjectionToken<_koobiq_components_core.KbqBaseFileUploadLocaleConfiguration | _koobiq_components_core.KbqMultipleFileUploadLocaleConfiguration>; // @public (undocumented) export const KBQ_MULTIPLE_FILE_UPLOAD_DEFAULT_CONFIGURATION: KbqMultipleFileUploadLocaleConfig; @@ -94,7 +94,7 @@ export class KbqDropzoneContent { autoCapture: boolean; }> | null; protected readonly localeService: _koobiq_components_core.KbqLocaleService | null; - protected readonly title: _angular_core.Signal; + protected readonly title: _angular_core.Signal; // (undocumented) static ɵcmp: _angular_core.ɵɵComponentDeclaration; // (undocumented) @@ -303,7 +303,7 @@ export class KbqMultipleFileUploadComponent extends KbqFileUploadBase implements allowed: _angular_core.InputSignal<"file" | "folder" | "mixed">; protected readonly captionContext: _angular_core.Signal; protected get captionTextWhenSelected(): string; - readonly configuration: KbqMultipleFileUploadLocaleConfig | null; + readonly configuration: _koobiq_components_core.KbqMultipleFileUploadLocaleConfiguration | null; protected readonly customFileIcon: _angular_core.Signal | undefined>; cvaOnChange: (_: KbqFileItem[]) => void; deleteFile(index: number, event?: MouseEvent, origin?: FocusOrigin): void; @@ -330,7 +330,7 @@ export class KbqMultipleFileUploadComponent extends KbqFileUploadBase implements get input(): ElementRef | undefined; readonly inputId: _angular_core.InputSignal; get invalid(): boolean; - readonly localeConfig: _angular_core.InputSignal | undefined>; + readonly localeConfig: _angular_core.InputSignal | undefined>; // (undocumented) ngAfterViewInit(): void; // (undocumented) @@ -341,7 +341,7 @@ export class KbqMultipleFileUploadComponent extends KbqFileUploadBase implements readonly progressMode: _angular_core.InputSignal; registerOnChange(fn: any): void; registerOnTouched(fn: any): void; - readonly resolvedLocaleConfig: _angular_core.Signal; + readonly resolvedLocaleConfig: _angular_core.Signal<_koobiq_components_core.KbqMultipleFileUploadLocaleConfiguration>; setDisabledState(isDisabled: boolean): void; // (undocumented) readonly size: _angular_core.InputSignal<"compact" | "default">; @@ -381,7 +381,7 @@ export class KbqSingleFileUploadComponent extends KbqFileUploadBase implements A // (undocumented) readonly inputId: _angular_core.InputSignal; get invalid(): boolean; - readonly localeConfig: _angular_core.InputSignal | undefined>; + readonly localeConfig: _angular_core.InputSignal | undefined>; // (undocumented) ngAfterViewInit(): void; // (undocumented) @@ -392,7 +392,7 @@ export class KbqSingleFileUploadComponent extends KbqFileUploadBase implements A readonly progressMode: _angular_core.InputSignal; registerOnChange(fn: any): void; registerOnTouched(fn: any): void; - readonly resolvedLocaleConfig: _angular_core.Signal; + readonly resolvedLocaleConfig: _angular_core.Signal<_koobiq_components_core.KbqBaseFileUploadLocaleConfiguration>; setDisabledState(isDisabled: boolean): void; readonly showFileSize: _angular_core.InputSignalWithTransform; writeValue(file: File | KbqFileItem | null): void; diff --git a/tools/public_api_guard/components/filter-bar.api.md b/tools/public_api_guard/components/filter-bar.api.md index b33dab8b78..fe90267fb4 100644 --- a/tools/public_api_guard/components/filter-bar.api.md +++ b/tools/public_api_guard/components/filter-bar.api.md @@ -22,6 +22,7 @@ import { InjectionToken } from '@angular/core'; import { KbqButton } from '@koobiq/components/button'; import { KbqButtonStyles } from '@koobiq/components/button'; import { KbqComponentColors } from '@koobiq/components/core'; +import { KbqDeepPartial } from '@koobiq/components/core'; import { KbqDropdownTrigger } from '@koobiq/components/dropdown'; import { KbqInput } from '@koobiq/components/input'; import { KbqListSelection } from '@koobiq/components/list'; @@ -35,7 +36,6 @@ import { KbqTreeFlattener } from '@koobiq/components/tree'; import { KbqTreeOption } from '@koobiq/components/tree'; import { KbqTreeSelect } from '@koobiq/components/tree-select'; import { KbqTreeSelection } from '@koobiq/components/tree'; -import * as _koobiq_components_core from '@koobiq/components/core'; import { ModelSignal } from '@angular/core'; import { Observable } from 'rxjs'; import { OnInit } from '@angular/core'; @@ -237,63 +237,9 @@ export interface KbqFilter { // @public (undocumented) export class KbqFilterBar implements KbqFilterBarHost { constructor(); - protected readonly changeDetectorRef: ChangeDetectorRef; // @deprecated readonly changes: BehaviorSubject; get configuration(): KbqFilterBarConfiguration; - set configuration(value: KbqFilterBarConfiguration); - // (undocumented) - readonly externalConfiguration: { - reset: { - buttonName: string; - }; - search: { - tooltip: string; - placeholder: string; - }; - filters: { - defaultName: string; - saveNewFilterTooltip: string; - searchPlaceholder: string; - searchEmptyResult: string; - saveAsNewFilter: string; - saveChanges: string; - saveAsNew: string; - change: string; - resetChanges: string; - remove: string; - name: string; - error: string; - errorHint: string; - saveButton: string; - cancelButton: string; - actionsTooltip: string; - }; - add: { - tooltip: string; - addedAnnouncement: string; - }; - refresher: { - refresh: string; - settings: string; - }; - pipe: { - clearButtonTooltip: string; - removeButtonTooltip: string; - applyButton: string; - emptySearchResult: string; - selectAll: string; - }; - datePipe: { - customPeriod: string; - customPeriodFrom: string; - customPeriodTo: string; - customPeriodErrorHint: string; - customPeriodMinIntervalErrorHint: string; - customPeriodMaxIntervalErrorHint: string; - backToPeriodSelection: string; - }; - } | null; readonly filter: _angular_core.ModelSignal; readonly filterReset: _angular_core.Signal; readonly filters: _angular_core.Signal; @@ -304,7 +250,6 @@ export class KbqFilterBar implements KbqFilterBarHost { readonly isReadOnly: _angular_core.Signal; readonly isSaved: _angular_core.Signal; readonly isSavedAndChanged: _angular_core.Signal; - protected readonly localeService: _koobiq_components_core.KbqLocaleService | null; readonly onChangePipe: _angular_core.OutputEmitterRef; readonly onClearPipe: _angular_core.OutputEmitterRef; readonly onClosePipe: _angular_core.OutputEmitterRef; @@ -339,7 +284,7 @@ export type KbqFilterBarConfiguration = typeof KBQ_FILTER_BAR_DEFAULT_CONFIGURAT // @public export interface KbqFilterBarHost { - configuration: KbqFilterBarConfiguration; + readonly configuration: KbqFilterBarConfiguration; readonly filter: ModelSignal; readonly internalFilterChanges: BehaviorSubject; readonly internalTemplatesChanges: BehaviorSubject; @@ -358,6 +303,9 @@ export interface KbqFilterBarHost { readonly selectedAllEqualsSelectedNothing: Signal; } +// @public +export const kbqFilterBarLocaleConfigurationProvider: (configuration: KbqDeepPartial) => Provider; + // @public (undocumented) export class KbqFilterBarModule { // (undocumented) diff --git a/tools/public_api_guard/components/input.api.md b/tools/public_api_guard/components/input.api.md index a267605958..dcd52588f8 100644 --- a/tools/public_api_guard/components/input.api.md +++ b/tools/public_api_guard/components/input.api.md @@ -18,7 +18,9 @@ import * as i2 from '@angular/forms'; import * as i3 from '@koobiq/components/icon'; import * as i8 from '@koobiq/components/form-field'; import { InjectionToken } from '@angular/core'; +import { KbqDeepPartial } from '@koobiq/components/core'; import { KbqFormFieldControl } from '@koobiq/components/form-field'; +import { KbqInputLocaleConfiguration } from '@koobiq/components/core'; import { KbqNumberInputLocaleConfig } from '@koobiq/components/core'; import { NgControl } from '@angular/forms'; import { NgForm } from '@angular/forms'; @@ -61,6 +63,12 @@ export const KBQ_INPUT_VALUE_ACCESSOR: InjectionToken<{ value: any; }>; +// @public +export const KBQ_NUMBER_INPUT_CONFIGURATION: InjectionToken; + +// @public +export const KBQ_NUMBER_INPUT_DEFAULT_CONFIGURATION: KbqInputLocaleConfiguration; + // @public (undocumented) export const KBQ_NUMBER_INPUT_VALUE_ACCESSOR: any; @@ -287,6 +295,9 @@ export class KbqNumberInput implements KbqFormFieldControl, ControlValueAcc static ɵfac: i0.ɵɵFactoryDeclaration; } +// @public +export const kbqNumberInputLocaleConfigurationProvider: (configuration: KbqDeepPartial) => Provider; + // @public (undocumented) export const MAX_VALIDATOR: Provider; diff --git a/tools/public_api_guard/components/navbar.api.md b/tools/public_api_guard/components/navbar.api.md index 8127b9a0b1..0ecd36ded5 100644 --- a/tools/public_api_guard/components/navbar.api.md +++ b/tools/public_api_guard/components/navbar.api.md @@ -20,17 +20,19 @@ import { IFocusableOption } from '@koobiq/components/core'; import { InjectionToken } from '@angular/core'; import { KbqButton } from '@koobiq/components/button'; import { KbqButtonCssStyler } from '@koobiq/components/button'; +import { KbqDeepPartial } from '@koobiq/components/core'; import { KbqFormField } from '@koobiq/components/form-field'; import { KbqIcon } from '@koobiq/components/icon'; +import { KbqNavbarLocaleConfiguration } from '@koobiq/components/core'; import { KbqTooltipTrigger } from '@koobiq/components/tooltip'; -import * as _koobiq_components_core from '@koobiq/components/core'; import { Observable } from 'rxjs'; import { OnDestroy } from '@angular/core'; +import { Provider } from '@angular/core'; import { QueryList } from '@angular/core'; import { Subject } from 'rxjs'; // @public -export const KBQ_VERTICAL_NAVBAR_CONFIGURATION: InjectionToken; +export const KBQ_VERTICAL_NAVBAR_CONFIGURATION: InjectionToken; // @public export const KBQ_VERTICAL_NAVBAR_DEFAULT_CONFIGURATION: { @@ -365,18 +367,14 @@ export class KbqVerticalNavbar extends KbqFocusableComponent implements AfterCon readonly animationDone: Subject; // (undocumented) readonly bento: i0.Signal; - // (undocumented) - configuration: any; + get configuration(): KbqNavbarLocaleConfiguration; // (undocumented) protected elementRef: ElementRef; // (undocumented) get expanded(): boolean; set expanded(value: boolean); // (undocumented) - readonly externalConfiguration: unknown; - // (undocumented) readonly items: i0.Signal; - protected readonly localeService: _koobiq_components_core.KbqLocaleService | null; // (undocumented) ngAfterContentInit(): void; // (undocumented) @@ -393,6 +391,9 @@ export class KbqVerticalNavbar extends KbqFocusableComponent implements AfterCon static ɵfac: i0.ɵɵFactoryDeclaration; } +// @public +export const kbqVerticalNavbarLocaleConfigurationProvider: (configuration: KbqDeepPartial) => Provider; + // (No @packageDocumentation comment for this package) ``` diff --git a/tools/public_api_guard/components/notification-center.api.md b/tools/public_api_guard/components/notification-center.api.md index 68614b206a..6e8decdb21 100644 --- a/tools/public_api_guard/components/notification-center.api.md +++ b/tools/public_api_guard/components/notification-center.api.md @@ -15,6 +15,8 @@ import { EventEmitter } from '@angular/core'; import * as i0 from '@angular/core'; import { InjectionToken } from '@angular/core'; import { KbqButton } from '@koobiq/components/button'; +import { KbqDeepPartial } from '@koobiq/components/core'; +import { KbqNotificationCenterLocaleConfiguration } from '@koobiq/components/core'; import { KbqPopUp } from '@koobiq/components/core'; import { KbqPopUpPlacementValues } from '@koobiq/components/core'; import { KbqPopUpSizeValues } from '@koobiq/components/core'; @@ -26,6 +28,7 @@ import * as _koobiq_components_core from '@koobiq/components/core'; import { Observable } from 'rxjs'; import { Overlay } from '@angular/cdk/overlay'; import { OverlayConfig } from '@angular/cdk/overlay'; +import { Provider } from '@angular/core'; import * as rxjs from 'rxjs'; import { ScrollStrategy } from '@angular/cdk/overlay'; import { Subscription } from 'rxjs'; @@ -33,7 +36,7 @@ import { TemplateRef } from '@angular/core'; import { Type } from '@angular/core'; // @public -export const KBQ_NOTIFICATION_CENTER_CONFIGURATION: InjectionToken; +export const KBQ_NOTIFICATION_CENTER_CONFIGURATION: InjectionToken; // @public export const KBQ_NOTIFICATION_CENTER_DEFAULT_CONFIGURATION: { @@ -67,15 +70,11 @@ export class KbqNotificationCenterComponent extends KbqPopUp implements AfterVie constructor(); protected readonly a11yLocaleConfiguration: i0.Signal<_koobiq_components_core.KbqA11yLocaleConfiguration>; protected readonly changeDetectorRef: ChangeDetectorRef; - // (undocumented) - configuration: any; + get configuration(): KbqNotificationCenterLocaleConfiguration; protected readonly dateAdapter: DateAdapter; escapeHandler(): void; - // (undocumented) - readonly externalConfiguration: unknown; isTrapFocus: boolean; - get localeData(): any; - protected readonly localeService: _koobiq_components_core.KbqLocaleService | null; + get localeData(): KbqNotificationCenterLocaleConfiguration; // (undocumented) ngAfterViewInit(): void; protected onContainerScroll(): void; @@ -98,6 +97,9 @@ export class KbqNotificationCenterComponent extends KbqPopUp implements AfterVie static ɵfac: i0.ɵɵFactoryDeclaration; } +// @public +export const kbqNotificationCenterLocaleConfigurationProvider: (configuration: KbqDeepPartial) => Provider; + // @public (undocumented) export class KbqNotificationCenterModule { // (undocumented) diff --git a/tools/public_api_guard/components/search-expandable.api.md b/tools/public_api_guard/components/search-expandable.api.md index 12f3addd21..ad76ed4fe6 100644 --- a/tools/public_api_guard/components/search-expandable.api.md +++ b/tools/public_api_guard/components/search-expandable.api.md @@ -12,9 +12,11 @@ import { DestroyRef } from '@angular/core'; import { FocusMonitor } from '@angular/cdk/a11y'; import * as i0 from '@angular/core'; import { InjectionToken } from '@angular/core'; -import * as _koobiq_components_core from '@koobiq/components/core'; +import { KbqDeepPartial } from '@koobiq/components/core'; +import { KbqSearchExpandableLocaleConfiguration } from '@koobiq/components/core'; import { NgControl } from '@angular/forms'; import { OnDestroy } from '@angular/core'; +import { Provider } from '@angular/core'; // @public (undocumented) export const defaultEmitValueTimeout = 200; @@ -23,7 +25,7 @@ export const defaultEmitValueTimeout = 200; export const defaultValue = ""; // @public -export const KBQ_SEARCH_EXPANDABLE_CONFIGURATION: InjectionToken; +export const KBQ_SEARCH_EXPANDABLE_CONFIGURATION: InjectionToken; // @public export const KBQ_SEARCH_EXPANDABLE_DEFAULT_CONFIGURATION: { @@ -35,23 +37,19 @@ export const KBQ_SEARCH_EXPANDABLE_DEFAULT_CONFIGURATION: { export class KbqSearchExpandable implements ControlValueAccessor, AfterViewInit, OnDestroy { constructor(); protected readonly changeDetectorRef: ChangeDetectorRef; - // (undocumented) - configuration: any; + get configuration(): KbqSearchExpandableLocaleConfiguration; protected readonly destroyRef: DestroyRef; // (undocumented) get disabled(): boolean; set disabled(value: boolean); readonly emitValueTimeout: i0.InputSignalWithTransform; - // (undocumented) - readonly externalConfiguration: unknown; protected readonly focusMonitor: FocusMonitor; readonly isEmitValueByEnterEnabled: i0.InputSignal; isOpened: boolean; readonly isOpenedChange: i0.OutputEmitterRef; // (undocumented) protected lastFocusOrigin: 'touch' | 'mouse' | 'keyboard' | 'program' | null; - get localeData(): any; - protected readonly localeService: _koobiq_components_core.KbqLocaleService | null; + get localeData(): KbqSearchExpandableLocaleConfiguration; protected readonly nativeElement: HTMLElement; // (undocumented) static ngAcceptInputType_disabled: unknown; @@ -86,6 +84,9 @@ export class KbqSearchExpandable implements ControlValueAccessor, AfterViewInit, static ɵfac: i0.ɵɵFactoryDeclaration; } +// @public +export const kbqSearchExpandableLocaleConfigurationProvider: (configuration: KbqDeepPartial) => Provider; + // @public (undocumented) export class KbqSearchExpandableModule { // (undocumented) diff --git a/tools/public_api_guard/components/select.api.md b/tools/public_api_guard/components/select.api.md index 4c40e58232..f57ebe8dbf 100644 --- a/tools/public_api_guard/components/select.api.md +++ b/tools/public_api_guard/components/select.api.md @@ -35,7 +35,6 @@ import { KbqAbstractSelect } from '@koobiq/components/core'; import { KbqCleaner } from '@koobiq/components/form-field'; import { KbqComponentColors } from '@koobiq/components/core'; import { KbqFormFieldControl } from '@koobiq/components/form-field'; -import { KbqLocaleService } from '@koobiq/components/core'; import { KbqOptgroup } from '@koobiq/components/core'; import { KbqOption } from '@koobiq/components/core'; import { KbqOptionBase } from '@koobiq/components/core'; @@ -153,7 +152,8 @@ export class KbqSelect extends KbqAbstractSelect implements AfterContentInit, On set hasBackdrop(value: boolean); // (undocumented) hiddenItems: number; - hiddenItemsText: string; + get hiddenItemsText(): string; + set hiddenItemsText(value: string); hiddenItemsTextFormatter(hiddenItemsText: string, hiddenItems: number): string; get id(): string; set id(value: string); @@ -163,8 +163,6 @@ export class KbqSelect extends KbqAbstractSelect implements AfterContentInit, On get isEmptySearchResult(): boolean; isRtl(): boolean; keyManager: ActiveDescendantKeyManager; - // (undocumented) - protected localeService?: KbqLocaleService | null | undefined; readonly multiline: _angular_core.InputSignalWithTransform; get multiple(): boolean; set multiple(value: boolean); @@ -240,7 +238,7 @@ export class KbqSelect extends KbqAbstractSelect implements AfterContentInit, On set selectAllHandler(fn: (event: KeyboardEvent, select: KbqSelect) => void); readonly selectAllOption: _angular_core.Signal; get selectAllState(): KbqPseudoCheckboxState; - protected selectAllText: string; + protected get selectAllText(): string; readonly selectAllToggle: _angular_core.InputSignalWithTransform; get selected(): KbqOptionBase | KbqOptionBase[]; readonly selectionChange: _angular_core.OutputEmitterRef; diff --git a/tools/public_api_guard/components/time-range.api.md b/tools/public_api_guard/components/time-range.api.md index f04b5868a1..0c39d71746 100644 --- a/tools/public_api_guard/components/time-range.api.md +++ b/tools/public_api_guard/components/time-range.api.md @@ -17,10 +17,12 @@ import { FormGroup } from '@angular/forms'; import { FormGroupDirective } from '@angular/forms'; import { InjectionToken } from '@angular/core'; import { Injector } from '@angular/core'; +import { KbqDeepPartial } from '@koobiq/components/core'; import { KbqFormFieldControl } from '@koobiq/components/form-field'; import { KbqPopoverTrigger } from '@koobiq/components/popover'; import { KbqTimepicker } from '@koobiq/components/timepicker'; -import { KbqTimeRangeLocaleConfig } from '@koobiq/components/core'; +import { KbqTimeRangeLocaleConfiguration } from '@koobiq/components/core'; +import * as _koobiq_components_core from '@koobiq/components/core'; import { NgControl } from '@angular/forms'; import { NgForm } from '@angular/forms'; import { Observable } from 'rxjs'; @@ -45,7 +47,7 @@ export const KBQ_CUSTOM_TIME_RANGE_TYPES: InjectionToken; // @public -export const KBQ_TIME_RANGE_LOCALE_CONFIGURATION: InjectionToken; +export const KBQ_TIME_RANGE_LOCALE_CONFIGURATION: InjectionToken; // @public (undocumented) export type KbqCustomTimeRangeType = { @@ -79,7 +81,7 @@ export class KbqTimeRange implements ControlValueAccessor, OnInit { readonly arrow: _angular_core.InputSignalWithTransform; readonly availableTimeRangeTypes: _angular_core.InputSignal; readonly defaultRangeValue: _angular_core.InputSignal | undefined>; - protected readonly localeConfiguration: WritableSignal; + protected readonly localeConfiguration: _angular_core.Signal; readonly maxDate: _angular_core.InputSignal; readonly minDate: _angular_core.InputSignal; readonly ngControl: NgControl | null; @@ -134,7 +136,7 @@ export class KbqTimeRangeEditor implements ControlValueAccessor, Validator, O protected readonly form: FormGroup>; protected readonly isRangeVisible: _angular_core.Signal; // (undocumented) - readonly localeConfiguration: _angular_core.InputSignal; + readonly localeConfiguration: _angular_core.InputSignal<_koobiq_components_core.KbqTimeRangeLocaleConfiguration>; readonly maxDate: _angular_core.InputSignal; readonly minDate: _angular_core.InputSignal; // (undocumented) @@ -161,7 +163,7 @@ export class KbqTimeRangeEditor implements ControlValueAccessor, Validator, O } // @public -export const kbqTimeRangeLocaleConfigurationProvider: (configuration: KbqTimeRangeLocaleConfig) => Provider; +export const kbqTimeRangeLocaleConfigurationProvider: (configuration: KbqDeepPartial) => Provider; // @public (undocumented) export class KbqTimeRangeModule { @@ -191,7 +193,7 @@ export class KbqTimeRangeTitle { // (undocumented) protected readonly injector: Injector; // (undocumented) - readonly localeConfiguration: _angular_core.InputSignal; + readonly localeConfiguration: _angular_core.InputSignal<_koobiq_components_core.KbqTimeRangeLocaleConfiguration>; // (undocumented) readonly timeRange: _angular_core.InputSignal; // (undocumented) diff --git a/tools/public_api_guard/components/timepicker.api.md b/tools/public_api_guard/components/timepicker.api.md index 7e950d8433..856224cfd6 100644 --- a/tools/public_api_guard/components/timepicker.api.md +++ b/tools/public_api_guard/components/timepicker.api.md @@ -14,9 +14,13 @@ import * as i1 from '@angular/cdk/a11y'; import * as i2 from '@angular/cdk/platform'; import * as i3 from '@angular/forms'; import * as i5 from '@koobiq/components/form-field'; +import { InjectionToken } from '@angular/core'; +import { KbqDeepPartial } from '@koobiq/components/core'; import { KbqFormFieldControl } from '@koobiq/components/form-field'; +import { KbqTimepickerLocaleConfiguration } from '@koobiq/components/core'; import { KbqTooltipTrigger } from '@koobiq/components/tooltip'; import { OnDestroy } from '@angular/core'; +import { Provider } from '@angular/core'; import { Subject } from 'rxjs'; import { ValidationErrors } from '@angular/forms'; import { Validator } from '@angular/forms'; @@ -39,6 +43,12 @@ export const HOURS_ONLY_REGEXP: RegExp; // @public (undocumented) export const HOURS_PER_DAY: number; +// @public +export const KBQ_TIMEPICKER_CONFIGURATION: InjectionToken; + +// @public +export const KBQ_TIMEPICKER_DEFAULT_CONFIGURATION: KbqTimepickerLocaleConfiguration; + // @public export const KBQ_TIMEPICKER_VALIDATORS: any; @@ -139,6 +149,9 @@ export class KbqTimepicker implements KbqFormFieldControl, ControlValueAcc static ɵfac: i0.ɵɵFactoryDeclaration, never>; } +// @public +export const kbqTimepickerLocaleConfigurationProvider: (configuration: KbqDeepPartial) => Provider; + // @public (undocumented) export class KbqTimepickerModule { // (undocumented) diff --git a/tools/public_api_guard/components/timezone.api.md b/tools/public_api_guard/components/timezone.api.md index d34b010cca..3f2dcfb644 100644 --- a/tools/public_api_guard/components/timezone.api.md +++ b/tools/public_api_guard/components/timezone.api.md @@ -4,7 +4,6 @@ ```ts -import { AfterContentInit } from '@angular/core'; import { AfterViewInit } from '@angular/core'; import { ElementRef } from '@angular/core'; import * as i0 from '@angular/core'; @@ -17,11 +16,15 @@ import * as i5 from '@koobiq/components/tags'; import * as i6 from '@koobiq/components/tooltip'; import * as i7 from '@angular/common'; import * as i8 from '@angular/cdk/a11y'; +import { InjectionToken } from '@angular/core'; +import { KbqDeepPartial } from '@koobiq/components/core'; import { KbqOption } from '@koobiq/components/core'; import { KbqSelect } from '@koobiq/components/select'; +import { KbqTimezoneLocaleConfiguration } from '@koobiq/components/core'; import { KbqTooltipTrigger } from '@koobiq/components/tooltip'; import { OnDestroy } from '@angular/core'; import { PipeTransform } from '@angular/core'; +import { Provider } from '@angular/core'; // @public export function filterCitiesBySearchString(cities: string, searchPattern?: string): string; @@ -29,6 +32,12 @@ export function filterCitiesBySearchString(cities: string, searchPattern?: strin // @public export function getZonesGroupedByCountry(data: KbqTimezoneZone[], otherCountriesLabel?: string, priorityCountry?: string): KbqTimezoneGroup[]; +// @public +export const KBQ_TIMEZONE_CONFIGURATION: InjectionToken; + +// @public +export const KBQ_TIMEZONE_DEFAULT_CONFIGURATION: KbqTimezoneLocaleConfiguration; + // @public (undocumented) export interface KbqTimezoneGroup { // (undocumented) @@ -39,6 +48,9 @@ export interface KbqTimezoneGroup { zones: KbqTimezoneZone[]; } +// @public +export const kbqTimezoneLocaleConfigurationProvider: (configuration: KbqDeepPartial) => Provider; + // @public (undocumented) export class KbqTimezoneModule { // (undocumented) @@ -95,12 +107,12 @@ export interface KbqTimezonesByCountry { } // @public (undocumented) -export class KbqTimezoneSelect extends KbqSelect implements AfterContentInit { +export class KbqTimezoneSelect extends KbqSelect { + constructor(); + get configuration(): KbqTimezoneLocaleConfiguration; // (undocumented) readonly customTrigger: i0.Signal; // (undocumented) - ngAfterContentInit(): void; - // (undocumented) static ɵcmp: i0.ɵɵComponentDeclaration; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration; diff --git a/tools/public_api_guard/components/tree-select.api.md b/tools/public_api_guard/components/tree-select.api.md index f12249a3dd..a44fda68be 100644 --- a/tools/public_api_guard/components/tree-select.api.md +++ b/tools/public_api_guard/components/tree-select.api.md @@ -119,7 +119,8 @@ export class KbqTreeSelect extends KbqAbstractSelect implements AfterContentInit // (undocumented) hiddenItems: number; // (undocumented) - hiddenItemsText: string; + get hiddenItemsText(): string; + set hiddenItemsText(value: string); // (undocumented) hiddenItemsTextFormatter(hiddenItemsText: string, hiddenItems: number): string; // (undocumented) diff --git a/tools/public_api_guard/components/tree.api.md b/tools/public_api_guard/components/tree.api.md index 6de1ca2287..dbcb7d995e 100644 --- a/tools/public_api_guard/components/tree.api.md +++ b/tools/public_api_guard/components/tree.api.md @@ -625,7 +625,7 @@ export class KbqTreeSelection extends KbqTreeBase implements ControlValueAc // (undocumented) selectAllOptions(allowDeselect?: boolean): void; get selectAllState(): KbqPseudoCheckboxState; - protected selectAllText: string; + protected get selectAllText(): string; readonly selectAllToggle: i0.InputSignalWithTransform; // (undocumented) readonly selectionChange: EventEmitter>;