@eagami/ui 5.19.0 → 5.20.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eagami/ui",
3
- "version": "5.19.0",
3
+ "version": "5.20.0",
4
4
  "description": "Lightweight, accessible, themeable Angular UI component library and icon set built on CSS custom properties",
5
5
  "author": "Michal Wiraszka <michal@eagami.com>",
6
6
  "license": "MIT",
@@ -2,6 +2,11 @@
2
2
  // @use '../../styles/mixins' as ea;
3
3
  // .ea-foo__close { @include ea.icon-button; }
4
4
 
5
+ // WCAG 2.2 SC 2.5.8 Target Size (Minimum), Level AA. Any pointer target the
6
+ // library ships has to reach this in at least one dimension pair, or qualify
7
+ // for one of the SC's exceptions (inline, spacing, essential).
8
+ $target-size-min: 24px;
9
+
5
10
  // Standard focus indicator. The ring is a soft box-shadow halo, but box-shadow
6
11
  // is dropped in forced-colors (Windows High Contrast) mode, which would leave
7
12
  // keyboard users with no visible focus. Pair the halo with a real outline that
@@ -47,6 +52,8 @@
47
52
  // it reads clearly inside the box regardless of how much padding the icon's own
48
53
  // viewBox carries (the feather `x`, for instance, only fills its middle half).
49
54
  @mixin icon-button {
55
+ // Anchors the grown pointer target below
56
+ position: relative;
50
57
  display: inline-flex;
51
58
  align-items: center;
52
59
  justify-content: center;
@@ -67,6 +74,25 @@
67
74
  font-size: 1.25em;
68
75
  }
69
76
 
77
+ // The visible box is deliberately small on the dense tiers (17.5px at 2xs,
78
+ // 21px at xs), which is under the 24px WCAG 2.2 SC 2.5.8 target minimum.
79
+ // SC 2.5.8 measures the interactive target rather than the painted control,
80
+ // so the target is grown here and the visuals are left alone.
81
+ //
82
+ // A layout that stacks or abuts these buttons closer than the minimum must
83
+ // opt out via `--ea-icon-button-target: 0`, because grown targets that
84
+ // overlap steal each other's clicks, which is worse than the undersized
85
+ // targets the SC's spacing exception already covers.
86
+ &::after {
87
+ content: '';
88
+ position: absolute;
89
+ top: 50%;
90
+ left: 50%;
91
+ width: max(100%, var(--ea-icon-button-target, #{$target-size-min}));
92
+ height: max(100%, var(--ea-icon-button-target, #{$target-size-min}));
93
+ transform: translate(-50%, -50%);
94
+ }
95
+
70
96
  &:hover {
71
97
  background-color: var(--color-state-hover);
72
98
  color: var(--color-text-primary);
@@ -0,0 +1,122 @@
1
+ /// <reference types="node" />
2
+ import { readFileSync, readdirSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+
5
+ /**
6
+ * Source-level guards for the accessibility floors the library commits to.
7
+ *
8
+ * These cannot be covered by the per-component axe specs: jsdom has no layout,
9
+ * so nothing at runtime can measure a rendered target box or a computed
10
+ * font-size. The floors are therefore asserted against the stylesheets that
11
+ * define them. Both floors regressed silently once, when the `2xs` tier scaled
12
+ * every em-derived dimension down a step without anything failing.
13
+ */
14
+
15
+ const SRC = process.cwd();
16
+
17
+ function read(relativePath: string): string {
18
+ return readFileSync(join(SRC, relativePath), 'utf8');
19
+ }
20
+
21
+ function componentStylesheets(): { name: string; css: string }[] {
22
+ const lib = join(SRC, 'src/lib');
23
+ return readdirSync(lib, { withFileTypes: true })
24
+ .filter(entry => entry.isDirectory())
25
+ .flatMap(dir =>
26
+ readdirSync(join(lib, dir.name))
27
+ .filter(file => file.endsWith('.component.scss'))
28
+ .map(file => ({
29
+ name: `${dir.name}/${file}`,
30
+ css: readFileSync(join(lib, dir.name, file), 'utf8'),
31
+ })),
32
+ );
33
+ }
34
+
35
+ /** Font-size in px of the smallest tier, from the typography tokens. */
36
+ const SMALLEST_TIER_PX = 10;
37
+
38
+ describe('WCAG floors', () => {
39
+ describe('SC 2.5.8 Target Size (Minimum)', () => {
40
+ const mixins = read('src/styles/_mixins.scss');
41
+
42
+ it('declares the 24px minimum as a single shared constant', () => {
43
+ expect(mixins).toMatch(/\$target-size-min:\s*24px;/);
44
+ });
45
+
46
+ it('grows the shared icon-button target to that minimum', () => {
47
+ const mixin = mixins.slice(mixins.indexOf('@mixin icon-button'));
48
+ const body = mixin.slice(0, mixin.indexOf('\n}\n'));
49
+
50
+ // The painted box stays small on the dense tiers, so the target has to be
51
+ // grown independently of width/height, defaulting to the shared minimum
52
+ expect(body).toContain('position: relative');
53
+ expect(body).toMatch(
54
+ /width:\s*max\(100%,\s*var\(--ea-icon-button-target,\s*#\{\$target-size-min\}\)\)/,
55
+ );
56
+ expect(body).toMatch(
57
+ /height:\s*max\(100%,\s*var\(--ea-icon-button-target,\s*#\{\$target-size-min\}\)\)/,
58
+ );
59
+ });
60
+
61
+ it('makes a component that shrinks the box decide about the grown target', () => {
62
+ // A box smaller than the minimum either grows its target or opts out; it
63
+ // must not silently keep a 24px target that can overlap its neighbour
64
+ const shrunk = componentStylesheets().filter(({ css }) =>
65
+ /--ea-icon-button-size:\s*(0|1(\.\d+)?)em/.test(css),
66
+ );
67
+
68
+ // Zero matches is a valid state: it means nothing currently shrinks the
69
+ // box below the minimum, which is the outcome this rule is steering toward
70
+ for (const { name, css } of shrunk) {
71
+ expect(`${name}: ${/--ea-icon-button-target:/.test(css)}`).toBe(`${name}: true`);
72
+ }
73
+ });
74
+
75
+ it('routes every icon button through the mixin rather than hand-rolling the box', () => {
76
+ const handRolled = componentStylesheets().filter(({ css }) =>
77
+ /\.ea-[a-z-]+__(close|clear|dismiss)\s*\{[^}]*\bwidth:/.test(css),
78
+ );
79
+
80
+ expect(handRolled.map(f => f.name)).toEqual([]);
81
+ });
82
+ });
83
+
84
+ describe('Legible floors for field sub-text', () => {
85
+ it('floors the field label at 12px however far the tier scales down', () => {
86
+ const css = read('src/lib/field/field-label.component.scss');
87
+
88
+ expect(css).toMatch(
89
+ /font-size:\s*max\(var\(--ea-field-label-size[^)]*\)[^,]*,\s*0\.75rem\)/,
90
+ );
91
+ });
92
+
93
+ it('floors error and hint text at 11px however far the tier scales down', () => {
94
+ const css = read('src/lib/field/field-messages.component.scss');
95
+
96
+ expect(css).toMatch(
97
+ /font-size:\s*max\(var\(--ea-field-messages-size[^)]*\)[^,]*,\s*0\.6875rem\)/,
98
+ );
99
+ });
100
+
101
+ it('keeps the floor at the point of use, not in each component that sets the variable', () => {
102
+ // 18 components declare these variables; a floor copied into each would
103
+ // be missed by the next one added
104
+ const setters = componentStylesheets().filter(({ css }) =>
105
+ /--ea-field-(label|messages)-size:/.test(css),
106
+ );
107
+
108
+ expect(setters.length).toBeGreaterThan(10);
109
+ for (const { name, css } of setters) {
110
+ expect(`${name}: ${/--ea-field-\w+-size:\s*max\(/.test(css)}`).toBe(
111
+ `${name}: false`,
112
+ );
113
+ }
114
+ });
115
+
116
+ it('would otherwise scale sub-text below the floors at the smallest tier', () => {
117
+ // Documents why the floors exist: the unclamped em values at 2xs
118
+ expect(SMALLEST_TIER_PX * 0.875).toBeLessThan(12);
119
+ expect(SMALLEST_TIER_PX * 0.8125).toBeLessThan(11);
120
+ });
121
+ });
122
+ });
@@ -147,10 +147,6 @@ interface EagamiMessages {
147
147
  hidePassword: string;
148
148
  clear: string;
149
149
  };
150
- numberInput: {
151
- increment: string;
152
- decrement: string;
153
- };
154
150
  menu: {
155
151
  label: string;
156
152
  };
@@ -2530,7 +2526,7 @@ declare class MultiSelectComponent implements ControlValueAccessor {
2530
2526
  /** Visual size of the number input. */
2531
2527
  type NumberInputSize = EaSize;
2532
2528
  /**
2533
- * Numeric field with increment and decrement steppers, min/max/step bounds, and
2529
+ * Numeric field with min/max/step bounds, native arrow-key stepping, and
2534
2530
  * the standard label, hint, and error chrome. The native input carries the
2535
2531
  * `spinbutton` role, so arrow keys step it; the steppers are pointer
2536
2532
  * affordances. Integrates with Angular forms via `ControlValueAccessor`.
@@ -2558,10 +2554,16 @@ declare class NumberInputComponent implements ControlValueAccessor {
2558
2554
  readonly required: _angular_core.InputSignal<boolean>;
2559
2555
  /** Minimum value; typed values are clamped to it on blur and the steppers respect it. */
2560
2556
  readonly min: _angular_core.InputSignal<number | undefined>;
2561
- /** Maximum value; typed values are clamped to it on blur and the steppers respect it. */
2557
+ /** Maximum value; typed values are clamped to it on blur. */
2562
2558
  readonly max: _angular_core.InputSignal<number | undefined>;
2563
- /** Amount each step (arrow key or stepper) adds or subtracts. */
2559
+ /** Amount each arrow-key step adds or subtracts. */
2564
2560
  readonly step: _angular_core.InputSignal<number>;
2561
+ /**
2562
+ * Caps how many characters the field accepts, counting the digits, the minus
2563
+ * sign, and the decimal point. Also sets how wide the field renders; without
2564
+ * it the field is six characters wide.
2565
+ */
2566
+ readonly maxDigits: _angular_core.InputSignal<number | undefined>;
2565
2567
  /** Whether negative values are allowed; when `false` the value floors at 0. */
2566
2568
  readonly allowNegative: _angular_core.InputSignal<boolean>;
2567
2569
  /** Accessible name for the field when no visible `label` is set. */
@@ -2588,8 +2590,6 @@ declare class NumberInputComponent implements ControlValueAccessor {
2588
2590
  readonly hasError: _angular_core.Signal<boolean>;
2589
2591
  readonly showError: _angular_core.Signal<boolean>;
2590
2592
  readonly showHint: _angular_core.Signal<boolean>;
2591
- readonly canIncrement: _angular_core.Signal<boolean>;
2592
- readonly canDecrement: _angular_core.Signal<boolean>;
2593
2593
  readonly wrapperClasses: _angular_core.Signal<{
2594
2594
  [x: string]: boolean;
2595
2595
  'ea-number-input-wrapper--error': boolean;
@@ -2603,20 +2603,17 @@ declare class NumberInputComponent implements ControlValueAccessor {
2603
2603
  registerOnTouched(fn: () => void): void;
2604
2604
  setDisabledState(isDisabled: boolean): void;
2605
2605
  protected handleInput(): void;
2606
+ private enforceMaxDigits;
2606
2607
  protected handleFocus(event: FocusEvent): void;
2607
2608
  protected handleBlur(event: FocusEvent): void;
2608
2609
  protected handleWheel(event: WheelEvent): void;
2609
2610
  protected handleKeydown(event: KeyboardEvent): void;
2610
- protected onStepMousedown(event: MouseEvent): void;
2611
- protected increment(): void;
2612
- protected decrement(): void;
2613
2611
  /** Moves keyboard focus to the underlying native input element. */
2614
2612
  focus(): void;
2615
- private stepBy;
2616
2613
  private commitFromElement;
2617
2614
  private clampToBounds;
2618
2615
  static ɵfac: _angular_core.ɵɵFactoryDeclaration<NumberInputComponent, never>;
2619
- static ɵcmp: _angular_core.ɵɵComponentDeclaration<NumberInputComponent, "ea-number-input", never, { "label": { "alias": "label"; "required": false; "isSignal": true; }; "placeholder": { "alias": "placeholder"; "required": false; "isSignal": true; }; "size": { "alias": "size"; "required": false; "isSignal": true; }; "hint": { "alias": "hint"; "required": false; "isSignal": true; }; "errorMsg": { "alias": "errorMsg"; "required": false; "isSignal": true; }; "errorMessages": { "alias": "errorMessages"; "required": false; "isSignal": true; }; "disabled": { "alias": "disabled"; "required": false; "isSignal": true; }; "readonly": { "alias": "readonly"; "required": false; "isSignal": true; }; "required": { "alias": "required"; "required": false; "isSignal": true; }; "min": { "alias": "min"; "required": false; "isSignal": true; }; "max": { "alias": "max"; "required": false; "isSignal": true; }; "step": { "alias": "step"; "required": false; "isSignal": true; }; "allowNegative": { "alias": "allowNegative"; "required": false; "isSignal": true; }; "ariaLabel": { "alias": "aria-label"; "required": false; "isSignal": true; }; "id": { "alias": "id"; "required": false; "isSignal": true; }; "value": { "alias": "value"; "required": false; "isSignal": true; }; }, { "value": "valueChange"; "changed": "changed"; "focused": "focused"; "blurred": "blurred"; }, never, never, true, never>;
2616
+ static ɵcmp: _angular_core.ɵɵComponentDeclaration<NumberInputComponent, "ea-number-input", never, { "label": { "alias": "label"; "required": false; "isSignal": true; }; "placeholder": { "alias": "placeholder"; "required": false; "isSignal": true; }; "size": { "alias": "size"; "required": false; "isSignal": true; }; "hint": { "alias": "hint"; "required": false; "isSignal": true; }; "errorMsg": { "alias": "errorMsg"; "required": false; "isSignal": true; }; "errorMessages": { "alias": "errorMessages"; "required": false; "isSignal": true; }; "disabled": { "alias": "disabled"; "required": false; "isSignal": true; }; "readonly": { "alias": "readonly"; "required": false; "isSignal": true; }; "required": { "alias": "required"; "required": false; "isSignal": true; }; "min": { "alias": "min"; "required": false; "isSignal": true; }; "max": { "alias": "max"; "required": false; "isSignal": true; }; "step": { "alias": "step"; "required": false; "isSignal": true; }; "maxDigits": { "alias": "maxDigits"; "required": false; "isSignal": true; }; "allowNegative": { "alias": "allowNegative"; "required": false; "isSignal": true; }; "ariaLabel": { "alias": "aria-label"; "required": false; "isSignal": true; }; "id": { "alias": "id"; "required": false; "isSignal": true; }; "value": { "alias": "value"; "required": false; "isSignal": true; }; }, { "value": "valueChange"; "changed": "changed"; "focused": "focused"; "blurred": "blurred"; }, never, never, true, never>;
2620
2617
  }
2621
2618
 
2622
2619
  /** Horizontal alignment of paginator controls within their container. */