@lifecorner/angular-ui-kit 3.27.1 → 3.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1790,3 +1790,105 @@ describe('Component pairings of 3.25.0 (request 039)', () => {
1790
1790
  });
1791
1791
  });
1792
1792
  });
1793
+
1794
+ /**
1795
+ * Request 044 (3.27.2, F-31, design 044 §5): the filled select, textarea and
1796
+ * datepicker draw the field boundary of the outline variant, --color-field-border
1797
+ * at rest and --color-text-secondary on hover (it was transparent at rest and
1798
+ * --color-border, 1.23 / 1.29, or nothing on hover). Pages are the background,
1799
+ * surface and surface-2; the field's own fill (--color-surface-sunken at rest,
1800
+ * --color-surface-hover on hover) is composited over each page.
1801
+ */
1802
+ describe('Component pairings of 3.27.2 (request 044)', () => {
1803
+ const lib = (path: string) => resolve(__dirname, '../lib', path);
1804
+ const fields = {
1805
+ select: {
1806
+ css: compile(lib('select/select.component.scss')).css,
1807
+ rest: '.lc-select.lc-select--filled',
1808
+ hover: '.lc-select.lc-select--filled:hover:not(.lc-select--disabled):not(.lc-select--error):not(:focus)',
1809
+ error: '.lc-select.lc-select--error',
1810
+ },
1811
+ textarea: {
1812
+ css: compile(lib('textarea/textarea.component.scss')).css,
1813
+ rest: '.lc-textarea--filled',
1814
+ hover: '.lc-textarea--filled:hover:not(:disabled):not(:read-only):not(.lc-textarea--error):not(:focus)',
1815
+ error: '.lc-textarea--error',
1816
+ },
1817
+ datepicker: {
1818
+ css: compile(lib('datepicker/datepicker.component.scss')).css,
1819
+ rest: '.lc-datepicker.lc-datepicker--filled',
1820
+ hover: '.lc-datepicker.lc-datepicker--filled:hover:not(:disabled):not([readonly]):not(.lc-datepicker--error):not(:focus)',
1821
+ error: '.lc-datepicker.lc-datepicker--filled.lc-datepicker--error',
1822
+ },
1823
+ };
1824
+ type Field = keyof typeof fields;
1825
+ const names = Object.keys(fields) as Field[];
1826
+
1827
+ const forcedBlock = /@media \(forced-colors: active\) \{((?:[^{}]*\{[^{}]*\})*)\s*\}/g;
1828
+ const rulesOf = (css: string) =>
1829
+ [...css.replace(forcedBlock, '').matchAll(/([^{}]+)\{([^{}]*)\}/g)].map((m) => ({
1830
+ selectors: m[1].split(/,(?![^(]*\))/).map((s) => s.trim()),
1831
+ body: m[2],
1832
+ }));
1833
+ const decls = (body: string): Record<string, string> =>
1834
+ Object.fromEntries([...body.matchAll(/([\w-]+):\s*([^;]+);/g)].map((m) => [m[1], m[2].replace(/\s+/g, ' ').trim()]));
1835
+ /** Every rule naming `selector`, merged in source order, forced-colours blocks left out. */
1836
+ const fills = (field: Field, selector: string): Record<string, string> => {
1837
+ const found = rulesOf(fields[field].css).filter((r) => r.selectors.includes(selector));
1838
+ expect([field, selector, found.length > 0]).toEqual([field, selector, true]);
1839
+ return Object.assign({}, ...found.map((r) => decls(r.body)));
1840
+ };
1841
+ const token = (value: string) => value.match(/^var\((--[\w-]+)\)$/)?.[1] ?? `not a token: ${value}`;
1842
+ const floor = (n: number) => Math.floor(n * 100) / 100;
1843
+
1844
+ it.each(names)('AC-8, AC-9: filled %s: field border on the sunken fill at rest, text-secondary on the hover fill', (field) => {
1845
+ const rest = fills(field, fields[field].rest);
1846
+ const hover = fills(field, fields[field].hover);
1847
+ expect([rest['background-color'], rest['border-color']]).toEqual(['var(--color-surface-sunken)', 'var(--color-field-border)']);
1848
+ expect([hover['background-color'], hover['border-color']]).toEqual(['var(--color-surface-hover)', 'var(--color-text-secondary)']);
1849
+ expect(fills(field, fields[field].error)['border-color']).toBe('var(--color-error-600)');
1850
+ });
1851
+
1852
+ // [light, dark] per page (background, surface, surface-2), design 044 §5.
1853
+ const expected = {
1854
+ restOnPage: [[4.83, 4.83, 4.62], [7.31, 6.7, 5.56]],
1855
+ restOnFill: [[4.39, 4.39, 4.39], [7.02, 7.02, 7.02]],
1856
+ hoverOnPage: [[7.55, 7.55, 7.23], [12.16, 11.15, 9.25]],
1857
+ hoverOnFill: [[6.86, 6.86, 6.86], [11.05, 9.97, 8.17]],
1858
+ // §5 quotes the page (background) only: 8.39 light, 9.47 dark.
1859
+ errorOnPage: [[8.39, 8.39, 8.02], [9.47, 8.68, 7.21]],
1860
+ errorOnFill: [[7.62, 7.62, 7.62], [9.09, 9.09, 9.09]],
1861
+ };
1862
+
1863
+ describe.each(palettes.map(([name, tokens], i) => [name, tokens, i] as const))('%s palette', (_palette, tokens, i) => {
1864
+ const c = (name: string) => color(tokens, name);
1865
+
1866
+ function measure(field: Field) {
1867
+ const pages = ['--color-background', '--color-surface', '--color-surface-2'].map(c);
1868
+ const rest = fills(field, fields[field].rest);
1869
+ const hover = fills(field, fields[field].hover);
1870
+ const error = fills(field, fields[field].error);
1871
+ const on = (ink: string, bases: Rgba[]) => bases.map((b) => floor(ratio(over(c(token(ink)), b), b)));
1872
+ const restFill = pages.map((p) => over(c(token(rest['background-color'])), p));
1873
+ const hoverFill = pages.map((p) => over(c(token(hover['background-color'])), p));
1874
+ return {
1875
+ restOnPage: on(rest['border-color'], pages),
1876
+ restOnFill: on(rest['border-color'], restFill),
1877
+ hoverOnPage: on(hover['border-color'], pages),
1878
+ hoverOnFill: on(hover['border-color'], hoverFill),
1879
+ errorOnPage: on(error['border-color'], pages),
1880
+ errorOnFill: on(error['border-color'], restFill),
1881
+ };
1882
+ }
1883
+
1884
+ it.each(names)('AC-8, AC-9: filled %s: the design 044 §5 ratios, all ≥ 3:1, hover ≥ rest', (field) => {
1885
+ const m = measure(field);
1886
+ for (const key of Object.keys(expected) as (keyof typeof expected)[]) {
1887
+ expect([key, m[key]]).toEqual([key, expected[key][i]]);
1888
+ expect(Math.min(...m[key])).toBeGreaterThanOrEqual(3);
1889
+ }
1890
+ m.hoverOnPage.forEach((r, page) => expect(r).toBeGreaterThanOrEqual(m.restOnPage[page]));
1891
+ m.hoverOnFill.forEach((r, page) => expect(r).toBeGreaterThanOrEqual(m.restOnFill[page]));
1892
+ });
1893
+ });
1894
+ });
@@ -181,7 +181,10 @@ describe('Touch targets, hover borders and contrast (request 043)', () => {
181
181
  .filter((r) => r.selectors.some((s) => field.test(s) && s.includes(':hover')))
182
182
  .map((r) => decls(r.body)['border-color'])
183
183
  .filter(Boolean);
184
- expect(borders.every((b) => b === 'var(--color-text-secondary)' || b === 'var(--color-border)')).toBe(true);
184
+ // Request 044 (F-31): --color-border (1.23:1 light) left the allow-list;
185
+ // the filled hovers moved to --color-text-secondary as well.
186
+ expect(borders.length).toBeGreaterThan(0);
187
+ expect(borders.every((b) => b === 'var(--color-text-secondary)')).toBe(true);
185
188
  });
186
189
 
187
190
  it('AC-3: the outline hover border (--color-text-secondary) is ≥ 3:1 on the page, the surface and surface-2', () => {
@@ -52,6 +52,12 @@ export type AppShellNavPlacement = 'below-header' | 'full-height';
52
52
  * content). The land and the footer run under the nav; the footer's content
53
53
  * starts at the nav width.
54
54
  *
55
+ * Toasts (3.28, AS-34): in the browser the shell tells `lc-toast-outlet` —
56
+ * wherever the app puts it, also after `</lc-app-shell>` — where its header
57
+ * ends and where the shown bar starts, so top toasts sit below the header and
58
+ * bottom toasts 16 px above the bar (from 1024 px: above the safe-area
59
+ * inset). `--lc-toast-outlet-offset-top` / `-bottom` override both.
60
+ *
55
61
  * CSS hooks (set them on `lc-app-shell` or any ancestor):
56
62
  * - `--lc-app-shell-nav-width` — the nav column from 1024 px. Default
57
63
  * `240px` (`248px` from 1440, `280px` from 1920).
@@ -104,6 +110,7 @@ export declare class AppShellComponent {
104
110
  readonly navPlacement: import("@angular/core").InputSignal<AppShellNavPlacement>;
105
111
  protected readonly shellClasses: import("@angular/core").Signal<string>;
106
112
  private readonly shell;
113
+ private readonly headerSlot;
107
114
  private readonly barSlot;
108
115
  constructor();
109
116
  static ɵfac: i0.ɵɵFactoryDeclaration<AppShellComponent, never>;
@@ -133,6 +140,14 @@ export type AppPagePaddingX = 'default' | 'header';
133
140
  * - `none` 0, `sm` 8 px, `md` 16 px at every width.
134
141
  */
135
142
  export type AppPagePaddingTop = 'default' | 'none' | 'sm' | 'md';
143
+ /**
144
+ * Width of `lc-app-page`'s content from 1280 px (3.28, AS-36).
145
+ * - `default`: the content column, centred, capped at `contentMaxWidth`.
146
+ * - `wide`: the standard column of a page with a right column, the right
147
+ * column left empty, and `[page-wide]` children running over it, up to
148
+ * 928 px. Below 1280 px, and next to a named right column, as `default`.
149
+ */
150
+ export type AppPageContentWidth = 'default' | 'wide';
136
151
  /**
137
152
  * A page inside `lc-app-shell`: the `<main>` landmark with the page's content
138
153
  * centred at a reading width, and an optional right column docked to the
@@ -150,6 +165,16 @@ export type AppPagePaddingTop = 'default' | 'none' | 'sm' | 'md';
150
165
  * inside main instead (e.g. a member row). It renders only with an
151
166
  * accessible name — `asideLabelledby` (the id of its heading) or
152
167
  * `asideAriaLabel`.
168
+ * - `reserveAside` keeps that column from 1280 px — width, tint and hairline —
169
+ * with no content and no landmark (`aria-hidden="true"`), so a page without
170
+ * right-column content keeps the same column as a page with one (3.28,
171
+ * AS-35). A named aside wins.
172
+ * - `contentWidth="wide"` (3.28, AS-36) lays the page out like a page with a
173
+ * right column, leaves the column empty and untinted, and lets children
174
+ * marked `[page-wide]` (e.g. a table's wrapper) run over it, up to 928 px;
175
+ * the other children (the page header, the next-step card) keep the
176
+ * standard column, so the title doesn't move between pages. Next to a named
177
+ * aside, and below 1280 px, it renders like `'default'`.
153
178
  * - `busy` marks the page as loading: `aria-busy="true"` on `<main>` while a
154
179
  * skeleton stands in for the content (AS-12). No visual change.
155
180
  * - `paddingX="header"` lines the content up with `lc-header` (3.15): its
@@ -220,8 +245,37 @@ export declare class AppPageComponent {
220
245
  * @default 'default'
221
246
  */
222
247
  readonly paddingTop: import("@angular/core").InputSignal<AppPagePaddingTop>;
248
+ /**
249
+ * Keeps the right column from 1280 px (360 · 400 · 460 px,
250
+ * `--lc-app-page-aside-width`) with its tint and hairline but no content
251
+ * and no landmark (`aria-hidden="true"`, no role), so pages without
252
+ * right-column content share one column with pages that have one. A named
253
+ * aside (`asideLabelledby` / `asideAriaLabel`) wins; `contentWidth="wide"`
254
+ * leaves the column empty and untinted. Below 1280 px nothing shows.
255
+ * @default false
256
+ */
257
+ readonly reserveAside: import("@angular/core").InputSignal<boolean>;
258
+ /**
259
+ * `'wide'`, from 1280 px: the page keeps the standard column of a page with
260
+ * a right column, the column stays empty and untinted, and `[page-wide]`
261
+ * children run over it, up to 928 px (or `contentMaxWidth` when it is not
262
+ * `880px`; `none` means the whole column). The other children keep the
263
+ * standard column. With a named aside, `'wide'` has no effect. Below
264
+ * 1280 px `'wide'` renders like `'default'`.
265
+ * @default 'default'
266
+ */
267
+ readonly contentWidth: import("@angular/core").InputSignal<AppPageContentWidth>;
223
268
  /** The right column renders only when it has an accessible name. */
224
269
  protected readonly hasAside: import("@angular/core").Signal<boolean>;
270
+ /** `contentWidth="wide"` takes effect only without a named aside, which it yields to. */
271
+ protected readonly wide: import("@angular/core").Signal<boolean>;
272
+ /** The empty, unnamed column (`reserveAside`), unless a named aside or `wide` takes the space. */
273
+ protected readonly reserved: import("@angular/core").Signal<boolean>;
274
+ /**
275
+ * The cap of `[page-wide]` children: `null` keeps the CSS's 928 px (also
276
+ * for the default `contentMaxWidth`), `none` means the whole column.
277
+ */
278
+ protected readonly wideMax: import("@angular/core").Signal<string | null>;
225
279
  protected readonly pageClasses: import("@angular/core").Signal<string>;
226
280
  static ɵfac: i0.ɵɵFactoryDeclaration<AppPageComponent, never>;
227
281
  static ɵcmp: i0.ɵɵComponentDeclaration<AppPageComponent, "lc-app-page", never, {
@@ -260,5 +314,15 @@ export declare class AppPageComponent {
260
314
  "required": false;
261
315
  "isSignal": true;
262
316
  };
317
+ "reserveAside": {
318
+ "alias": "reserveAside";
319
+ "required": false;
320
+ "isSignal": true;
321
+ };
322
+ "contentWidth": {
323
+ "alias": "contentWidth";
324
+ "required": false;
325
+ "isSignal": true;
326
+ };
263
327
  }, {}, never, ["*", "[page-aside]"], true, never>;
264
328
  }
@@ -28,4 +28,25 @@ export declare class OverlayStackService {
28
28
  claim(id: string, event: Event): boolean;
29
29
  static ɵfac: i0.ɵɵFactoryDeclaration<OverlayStackService, never>;
30
30
  static ɵprov: i0.ɵɵInjectableDeclaration<any>;
31
+ }
32
+ /**
33
+ * The space a fixed overlay keeps free at the top and the bottom of the
34
+ * viewport, as CSS lengths (3.28, AS-34). `lc-app-shell` writes them in the
35
+ * browser: `top` is its header's bottom edge, `bottom` the shown bottom bar's
36
+ * top edge, measured from the viewport's bottom (or the safe-area insets when
37
+ * there is no header or no bar). `lc-toast-outlet` reads them, so its stacks
38
+ * clear the header and the bar even when the app renders the outlet outside
39
+ * the shell (no custom property can inherit there).
40
+ *
41
+ * `null` means "no shell": on the server, before the shell has measured,
42
+ * without ResizeObserver and after the shell is destroyed. Internal to the
43
+ * library; not part of the public API and without a semver guarantee.
44
+ */
45
+ export declare class OverlayInsetsService {
46
+ /** CSS length kept free above top-edge overlays, e.g. `'56px'`. */
47
+ readonly top: import("@angular/core").WritableSignal<string | null>;
48
+ /** CSS length kept free below bottom-edge overlays, e.g. `'102px'`. */
49
+ readonly bottom: import("@angular/core").WritableSignal<string | null>;
50
+ static ɵfac: i0.ɵɵFactoryDeclaration<OverlayInsetsService, never>;
51
+ static ɵprov: i0.ɵɵInjectableDeclaration<any>;
31
52
  }
@@ -1,5 +1,6 @@
1
1
  import * as i0 from "@angular/core";
2
2
  import { Signal } from "@angular/core";
3
+ import { OverlayInsetsService } from "@lifecorner/angular-ui-kit/internal/overlay";
3
4
  export type ToastVariant = 'success' | 'error' | 'warning' | 'info';
4
5
  export type ToastPosition = 'top-left' | 'top-center' | 'top-right' | 'bottom-left' | 'bottom-center' | 'bottom-right';
5
6
  export interface ToastAction {
@@ -181,11 +182,35 @@ type ToastLive = 'polite' | 'assertive';
181
182
  * into an *existing* live region — one `polite` for success/info toasts and one
182
183
  * `assertive` for error/warning toasts. Dismissing a toast (close button,
183
184
  * action, auto-dismiss) removes it from the service.
185
+ *
186
+ * Offsets (3.28, AS-34): with `lc-app-shell` in the app (the outlet may sit
187
+ * before or after it), the `top-*` stacks start below the shell's header and
188
+ * the `bottom-*` stacks end above its bottom bar, each with the stack's 16 px
189
+ * padding as the gap. The shell measures both in the browser (the header's
190
+ * bottom edge with the top inset; the shown bar's top edge, from the
191
+ * viewport's bottom, with the bottom inset). From 1024 px, where the bar is
192
+ * hidden, the bottom offset is the safe-area inset. Without a shell, and in
193
+ * prerendered HTML, both are `0px`, as before. Every stack is at most as high
194
+ * as the space between the offsets and scrolls when its toasts need more
195
+ * (200 % text), so each toast stays reachable. In the shell, prefer the
196
+ * `bottom-*` positions: a top toast can still cover a page title placed
197
+ * right under the header.
198
+ *
199
+ * CSS hooks (set them on `lc-toast-outlet` or any ancestor, e.g. `:root`;
200
+ * each wins over the shell's value; write lengths, `0px` rather than `0`):
201
+ * - `--lc-toast-outlet-offset-top` — the space kept free above the `top-*`
202
+ * stacks. Default `0px`; with `lc-app-shell`: its header's bottom edge, or
203
+ * `env(safe-area-inset-top, 0px)` without a header.
204
+ * - `--lc-toast-outlet-offset-bottom` — the space kept free below the
205
+ * `bottom-*` stacks. Default `0px`; with `lc-app-shell`: the shown bottom
206
+ * bar's top edge, measured from the viewport's bottom, or
207
+ * `env(safe-area-inset-bottom, 0px)` when no bar shows.
184
208
  */
185
209
  export declare class ToastOutletComponent {
186
210
  protected readonly positions: readonly ToastPosition[];
187
211
  protected readonly liveLevels: readonly ToastLive[];
188
212
  private readonly toastService;
213
+ protected readonly insets: OverlayInsetsService;
189
214
  /** Accessible name of every toast's close button (forwarded to lc-toast). */
190
215
  readonly closeAriaLabel: import("@angular/core").InputSignal<string>;
191
216
  /** Toasts keyed by `${position}:${politeness}` in the order they were shown. */