@design.estate/dees-catalog 3.97.1 → 3.98.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.
Files changed (22) hide show
  1. package/dist_bundle/bundle.js +4606 -3404
  2. package/dist_ts_web/00_commitinfo_data.js +1 -1
  3. package/dist_ts_web/elements/00group-button/dees-button/dees-button.d.ts +35 -0
  4. package/dist_ts_web/elements/00group-button/dees-button/dees-button.js +128 -5
  5. package/dist_ts_web/elements/00group-feedback/dees-spinner/dees-spinner.js +12 -3
  6. package/dist_ts_web/elements/00group-form/dees-form-submit/dees-form-submit.d.ts +9 -0
  7. package/dist_ts_web/elements/00group-form/dees-form-submit/dees-form-submit.js +25 -2
  8. package/dist_ts_web/elements/00group-input/dees-input-text/dees-input-text.d.ts +15 -0
  9. package/dist_ts_web/elements/00group-input/dees-input-text/dees-input-text.js +36 -2
  10. package/dist_ts_web/elements/00group-simple/dees-simple-login/dees-simple-login.d.ts +246 -0
  11. package/dist_ts_web/elements/00group-simple/dees-simple-login/dees-simple-login.demo.js +439 -21
  12. package/dist_ts_web/elements/00group-simple/dees-simple-login/dees-simple-login.js +857 -23
  13. package/package.json +3 -3
  14. package/readme.hints.md +35 -0
  15. package/readme.md +80 -11
  16. package/ts_web/00_commitinfo_data.ts +1 -1
  17. package/ts_web/elements/00group-button/dees-button/dees-button.ts +128 -2
  18. package/ts_web/elements/00group-feedback/dees-spinner/dees-spinner.ts +11 -2
  19. package/ts_web/elements/00group-form/dees-form-submit/dees-form-submit.ts +18 -0
  20. package/ts_web/elements/00group-input/dees-input-text/dees-input-text.ts +29 -0
  21. package/ts_web/elements/00group-simple/dees-simple-login/dees-simple-login.demo.ts +418 -19
  22. package/ts_web/elements/00group-simple/dees-simple-login/dees-simple-login.ts +969 -20
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@design.estate/dees-catalog",
3
- "version": "3.97.1",
3
+ "version": "3.98.0",
4
4
  "private": false,
5
5
  "description": "A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.",
6
6
  "main": "dist_ts_web/index.js",
@@ -35,11 +35,11 @@
35
35
  "devDependencies": {
36
36
  "@design.estate/dees-wcctools": "^3.9.2",
37
37
  "@git.zone/tsbuild": "^4.4.2",
38
- "@git.zone/tsbundle": "^2.11.0",
38
+ "@git.zone/tsbundle": "^2.11.3",
39
39
  "@git.zone/tstest": "^3.6.6",
40
40
  "@git.zone/tswatch": "^3.3.5",
41
41
  "@push.rocks/projectinfo": "^5.1.0",
42
- "@types/node": "^26.1.0"
42
+ "@types/node": "^26.1.1"
43
43
  },
44
44
  "files": [
45
45
  "ts/**/*",
package/readme.hints.md CHANGED
@@ -1114,3 +1114,38 @@ Some components appear in multiple groups via `demoGroups = ['Primary', 'Seconda
1114
1114
  - Tool cards resolve their kind through `DeesHarnessToolRegistry.default` (open set + `kind` override), so consumers register custom tools without forking components; `server__tool` names auto-classify as MCP with annotation badges.
1115
1115
  - The syntax palette moved to the shared `ts_web/elements/00syntax.ts` (`syntaxPaletteStyles` + `syntaxScopeStyles`); codebox and harness markdown consume it — don't re-declare `--syntax-*`/hljs scope colors per component.
1116
1116
  - `dees-dataview-codebox` diff mode: set `codeBefore` (against `codeToDisplay`) or a pre-computed `unifiedDiff` patch; `diffView` = `'inline' | 'split'`. Engine lives dependency-free in `codebox.diff.ts` (LCS + intraline segments + context folding + `toUnifiedDiff`). Tool cards use it for file-write bodies via the `diffBlock` render helper.
1117
+
1118
+ ## dees-simple-login multi-method rework (2026-07)
1119
+
1120
+ - **The catalog owns no WebAuthn protocol.** A passkey ceremony needs server-issued options and server-side verification, so `@simplewebauthn/browser` is deliberately *not* a dependency. Two mutually exclusive plug-in modes: no handler set → the component dispatches `passkey-login` / `passkey-register` / `provider-login` and stops; handler set (`passkeyLoginHandler` etc.) → the component awaits it, owns the busy state, turns a rejection into that method's error, and does **not** dispatch the request event. Suppressing the event in handler mode is what makes a double-started ceremony impossible — two concurrent `navigator.credentials.get()` calls reject each other.
1121
+ - **`login` is the one exception**: it is a notification, not a request, and always fires with dees-form's detail verbatim (`{ data: { username, password } }`) because cloudly, dcrouter and gitops depend on it.
1122
+ - **Configuration decides availability, `methodOrder` only reorders.** `passkey` (false), `providers` ([]), `password` (true) decide *whether* a method renders; `methodOrder` decides the sequence and appends anything it omits, so an incomplete order can never silently hide a configured method. Default therefore resolves to `['password']`, so the zero-configuration card is the same username/password form with the same API surface. It is **not** pixel-identical to the pre-rework component: three deliberate visual changes ship with it — the submit button is now card-width (the pre-existing `dees-tile dees-form-submit { width: 100% }` never worked because the host is `inline-block`), `.subheader` moved from `--dees-color-text-muted` (0.30 alpha, ~2:1) to `--dees-color-text-secondary`, and `.loginContainer` gained `box-sizing`, `padding` and `overflow-y: auto` so a tall multi-method card scrolls instead of clipping.
1123
+ - **Shadow-DOM reach-in is a real contract.** All three consumers do `shadowRoot.querySelector('dees-form')` then `form.setStatus()` / `form.reset()`, and cloudly's `switchToLoginContent()` hand-reverses the inline styles `switchToSlottedContent()` sets. So: `.loginContainer` / `.login` / `.slotContainer` must keep their names and inline-style choreography, the password `dees-form` must stay the first and only form in shadow order, and the `formData` listener is bound declaratively (`@formData`) because the form is now conditionally rendered and a one-shot `firstUpdated` hook-up would silently stop firing.
1124
+ - **Do not bind `.text` on the credentials `dees-form-submit`.** `dees-form.setStatus(state, text)` writes that property imperatively, and `dees-form.reset()` ends with `setStatus('normal', 'Submit')`. A Lit property binding would clobber a consumer's status text on the next render, so the label is slotted instead — which also means `labels.passwordSubmit` is read at first render only.
1125
+ - The credentials form is wrapped in `dees-tile` **only when password is the sole method**. Once the column holds other methods the column is the card, and a nested tile would inset the fields 24px against full-width buttons and read as a stray box.
1126
+ - Additive primitive gaps closed on the way: `dees-button.fullWidth` (`full-width` attribute — the host being `inline-block` meant `width: 100%` never stretched the `inline-flex` face; the opt-in path also adds `min-width: 0` + label ellipsis), the same forwarded on `dees-form-submit`, `dees-input-text.autocomplete` (needed for `username webauthn` conditional mediation), and `--dees-spinner-color` on `dees-spinner` (it hard-coded `--dees-color-text-primary`, painting a near-black arc on an accent button face in bright theme).
1127
+ - **Never mark a pending `dees-button` disabled** — `.button.disabled { opacity: 0.5 }` washes out the spinner. Guard re-entry in the handler instead; only *sibling* buttons get `disabled`.
1128
+ - Known pre-existing gaps this rework did **not** change: `--dees-color-text-secondary` is 3.44:1 in bright theme (sub-AA for 13px copy); `--dees-color-accent-primary` with white text is 3.65–4.02:1; `dees-tile`'s 16px heading padding vs the 24px content padding this component sets leaves an 8px misalignment; `dees-spinner`'s success/error faces still hard-code `--dees-color-text-primary`.
1129
+
1130
+ ## dees-button keyboard operability (2026-07)
1131
+
1132
+ `dees-button` renders its face as a `<div class="button">`, so it had no keyboard affordance at all — nothing built on it (including every action in `dees-simple-login`) was reachable by Tab. Fixed in the primitive rather than per consumer.
1133
+
1134
+ - The face carries `role="button"`, `tabindex` (`0`, or `-1` when `disabled`), `aria-disabled`, and `aria-busy` while `status === 'pending'`. A pending button stays focusable on purpose — pulling focus mid-ceremony strands the user.
1135
+ - **Enter activates on keydown, Space on keyup**, matching native buttons. Space keydown is swallowed so the page cannot scroll under a focused button. Verified with real CDP key events: Space activates without moving a scrollable ancestor's `scrollTop`.
1136
+ - **Keyboard activation goes through a real `click()` on the face, not a direct `clicked` dispatch.** `dees-workspace-diff-editor` (5 call sites) and `profilepicture.modal` (2) bind plain `@click` on `dees-button` rather than `@clicked`; only a real click drives both. `pointer-events: none` does not block a programmatic `click()`, so inertness is enforced in `activateFromKeyboard()` — refusing `disabled` *and* any non-`normal` status, because the status faces already set `pointer-events: none` for the mouse.
1137
+ - `DeesButton.focus()` / `.blur()` forward to the face; without that, `host.focus()` is a silent no-op.
1138
+ - **`dees-form-submit.focus()` is NOT a focus method — it submits.** `dees-form.addBehaviours()` calls `getSubmitButton()?.focus()` to implement "Enter in the last field submits the form". An earlier attempt to make it forward focus properly broke that; it must stay as-is. Keyboard focus reaches the submit through the inner `dees-button` face instead. Pinned by a test.
1139
+ - The `:focus-visible` ring uses the **opaque** `--dees-color-accent-primary`, not `--dees-color-focus-ring`. That shared token is a 0.45/0.55 alpha and composites to 1.85:1 (bright) / 2.31:1 (dark) against the surface behind the button — under the 3:1 WCAG 2.2 SC 1.4.11 wants, and a navy hairline on a dark canvas. Raising the alpha on the token itself would fix every other control too and is the wider change.
1140
+ ### Focus containment across the login/app crossfade
1141
+
1142
+ Both halves of `dees-simple-login` live in the DOM permanently, stacked and cross-faded with `opacity` + `pointer-events`. That was sufficient only while nothing inside them could hold focus. Once `dees-button` faces became focusable, `pointer-events: none` stopped containing anything — it does not remove a tab stop, and keyboard activation deliberately bypasses it — so the hidden half became reachable in both directions: Tab out of the password form into the invisible app shell, or Tab back into the invisible login card after signing in and start a passkey ceremony there.
1143
+
1144
+ `inert` is the only thing that removes a hidden-but-rendered subtree from focus and the accessibility tree. It is derived from each container's effective `pointer-events` via a `MutationObserver` on the inline styles, **not** from an internal flag, because consumers reverse the transition by hand: cloudly's `switchToLoginContent()` writes `.login` / `.loginContainer` / `.slotContainer` inline styles directly and knows nothing about `inert`. Keying off a flag it cannot reset would leave its login card permanently inert after a session expiry. Observe only `attributeFilter: ['style']` — `inert` reflects to its own attribute and would otherwise re-trigger the observer.
1145
+
1146
+ ### Open follow-ups from this change (separate decisions, deliberately not done here)
1147
+
1148
+ - **`--dees-color-focus-ring` is still sub-3:1 for every control other than `dees-button`.** Inputs, dropdowns and the rest still use the shared token at 0.45/0.55 alpha, which composites to 1.85:1 (bright) / 2.31:1 (dark). `dees-button` was fixed locally by switching its ring to the opaque accent, so the catalog is now *inconsistent*: one control has a compliant focus indicator and the others do not. Raising the token's alpha is the wider fix and needs its own review across every control that reads it.
1149
+ - **`dees-button-exit` is unaudited and very likely has the same keyboard defect.** It renders a decorative X glyph (`.maincontainer` + two line divs) with no click handler, no `role` and no `tabindex`; consumers wire clicks on the host. It is not a `dees-button` consumer, so the keyboard fix does not reach it.
1150
+
1151
+ - Consumer audit: 16 non-demo components use `dees-button`. None wrap it in a `role="menu"/"listbox"/"option"/"tab"` container, none attach keyboard handlers to the button or an ancestor of it (`dees-harness-composer` and `dees-harness-question-card` bind keydown on their own `textarea`/`input`), `dees-modal` has no focus trap, `dees-button-group` only slots, and no component binds both `@click` and `@clicked` on the same button — so no double activation and no nested-role reporting change.
package/readme.md CHANGED
@@ -90,10 +90,15 @@ button.text = 'Click me';
90
90
  shape="pill" // Options: squircle (default rounded rect), pill (capsule for standalone actions)
91
91
  status="pending" // Options: normal, pending, success, error
92
92
  disabled={false} // Optional: disables the button
93
+ full-width // Optional: stretches the button face to the host width
93
94
  @click=${handleClick}
94
95
  >Click me</dees-button>
95
96
  ```
96
97
 
98
+ The button is keyboard operable: its face carries `role="button"` and `tabindex`, Enter and
99
+ Space activate it (dispatching a real `click`, so both `@click` and `@clicked` fire), a
100
+ disabled button leaves the tab order, and keyboard focus draws a `:focus-visible` ring.
101
+
97
102
  #### `DeesBadge`
98
103
  Display status indicators or counts with customizable styles.
99
104
 
@@ -157,12 +162,22 @@ Loading indicator with customizable appearance.
157
162
 
158
163
  ```typescript
159
164
  <dees-spinner
160
- size="medium" // Options: small, medium, large
161
- type="primary" // Options: primary, secondary, light, dark
162
- overlay // Optional: adds a full-screen overlay
165
+ .size=${20} // Optional: diameter in pixels (default 20)
166
+ .status=${'normal'} // Optional: 'normal' | 'pending' | 'success' | 'error'
167
+ .bnw=${true} // Optional: black-and-white treatment
163
168
  ></dees-spinner>
164
169
  ```
165
170
 
171
+ The arc colour follows the `--dees-spinner-color` custom property, falling back to
172
+ `--dees-color-text-primary`. Set it on an ancestor when the spinner sits on a coloured
173
+ surface — an accent button face, for example — so the arc matches that surface's foreground:
174
+
175
+ ```css
176
+ dees-button {
177
+ --dees-spinner-color: var(--dees-color-on-accent);
178
+ }
179
+ ```
180
+
166
181
  #### `DeesToast`
167
182
  Notification toast messages with various styles, positions, and auto-dismiss functionality.
168
183
 
@@ -325,6 +340,7 @@ Text input field with validation, info tooltips, description text, and context m
325
340
  value="initial@value.com" // Initial value
326
341
  required // Makes the field required
327
342
  disabled // Disables the input
343
+ .autocomplete=${'username'} // Autofill hint; unset renders no autocomplete attribute
328
344
  .infoText=${'Hover icon tooltip text'} // Shows ⓘ icon on label with hover tooltip
329
345
  .description=${'Permanent help text below the input'} // Small text below the input
330
346
  .validationFunction=${(value) => { // Auto-validates on every keystroke
@@ -711,9 +727,14 @@ Submit button component specifically designed for `DeesForm`.
711
727
  <dees-form-submit
712
728
  disabled // Optional: disable submit button
713
729
  status="normal" // Options: normal, pending, success, error
730
+ full-width // Optional: stretches the button face to the host width
714
731
  >Submit Form</dees-form-submit>
715
732
  ```
716
733
 
734
+ Note that `DeesFormSubmit.focus()` submits rather than focuses — `DeesForm` calls it to
735
+ implement "Enter in the last field submits". Keyboard focus reaches the submit through the
736
+ inner `DeesButton` face.
737
+
717
738
  ---
718
739
 
719
740
  ### App Shell (Layout) Components
@@ -1848,21 +1869,69 @@ Simple application dashboard component for quick prototyping.
1848
1869
  ```
1849
1870
 
1850
1871
  #### `DeesSimpleLogin`
1851
- Simple login form component with validation and customization.
1872
+
1873
+ Login surface for an application shell. Offers up to three authentication methods in one
1874
+ card — passkeys, identity providers ("sign in with …") and a username/password form — and
1875
+ renders the authenticated app through its default slot.
1876
+
1877
+ A method is offered only when it is **configured**, so the zero-configuration default is the
1878
+ password form alone:
1879
+
1880
+ ```typescript
1881
+ <dees-simple-login name="My Application" @login=${handleLogin}>
1882
+ <!-- authenticated content -->
1883
+ </dees-simple-login>
1884
+ ```
1885
+
1886
+ `login` fires with `detail.data.{username,password}`; call `switchToSlottedContent()` once
1887
+ credentials check out.
1888
+
1889
+ Turn on more methods and they render in the canonical order passkey → provider → password:
1852
1890
 
1853
1891
  ```typescript
1854
1892
  <dees-simple-login
1855
- .appName=${'My Application'}
1856
- .logo=${'./assets/logo.png'}
1857
- .backgroundImage=${'./assets/background.jpg'}
1858
- .fields=${['username', 'password']}
1859
- showForgotPassword
1860
- showRememberMe
1893
+ name="login.idp.global"
1894
+ .passkey=${true}
1895
+ .providers=${[
1896
+ { id: 'idp-global', label: 'idp.global', icon: 'lucide:shieldCheck' },
1897
+ { id: 'workspace', label: 'Workspace SSO', icon: 'lucide:building2' },
1898
+ ]}
1899
+ .passkeyLoginHandler=${async (context) => runWebAuthnCeremony(context)}
1900
+ .providerLoginHandler=${async (context) => redirectTo(context.providerId)}
1861
1901
  @login=${handleLogin}
1862
- @forgot-password=${handleForgotPassword}
1863
1902
  ></dees-simple-login>
1864
1903
  ```
1865
1904
 
1905
+ **Configuration** — `passkey` (default `false`), `providers` (default `[]`), `password`
1906
+ (default `true`), `passkeyIntents` (`['authenticate']`, add `'register'` to offer
1907
+ enrollment), `methodOrder`, `labels`, `passkeyAvailable` (override capability detection),
1908
+ `passkeyAutofill` (WebAuthn conditional mediation — inert unless `passkeyLoginHandler` is set,
1909
+ the password method renders a username field, and the browser reports conditional mediation).
1910
+
1911
+ **Who owns the WebAuthn ceremony** — not this component. A ceremony needs server-issued
1912
+ options and server-side verification, so the catalog carries no WebAuthn dependency. Plug
1913
+ one in either way, never both for the same interaction:
1914
+
1915
+ - **Event mode** (no handler set) — the component dispatches `passkey-login`,
1916
+ `passkey-register` or `provider-login` and stops. Report progress back with `setBusy()` /
1917
+ `reportError()`.
1918
+ - **Handler mode** (`passkeyLoginHandler`, `passkeyRegisterHandler`, `providerLoginHandler`,
1919
+ `passwordLoginHandler`) — the component awaits the handler, owns that method's busy state
1920
+ and turns a rejection into that method's error message. The request event is not
1921
+ dispatched, so a ceremony can never start twice.
1922
+
1923
+ `login` is a notification rather than a request and always fires, even in handler mode.
1924
+
1925
+ Busy and error state is independent per method, per passkey intent and per provider, so a
1926
+ failed passkey attempt never blanks the password form. `reportError(target, message)`,
1927
+ `setBusy(target, busy)`, `isBusy(target)` and `getError(target)` each take either a
1928
+ `TDeesLoginMethod` or an `IDeesLoginTarget` (`{ method, intent?, providerId? }`).
1929
+ `clearError(target?)` takes an optional target and clears every error when called without
1930
+ one; `reset()` takes no arguments and clears all busy and error state plus the password form.
1931
+
1932
+ Provider icons are always consumer-supplied Lucide names — the catalog ships no third-party
1933
+ brand assets.
1934
+
1866
1935
  ---
1867
1936
 
1868
1937
  ### Shopping Components
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@design.estate/dees-catalog',
6
- version: '3.97.1',
6
+ version: '3.98.0',
7
7
  description: 'A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.'
8
8
  }
@@ -78,6 +78,15 @@ export class DeesButton extends DeesElement {
78
78
  })
79
79
  accessor insideForm: boolean = false;
80
80
 
81
+ /**
82
+ * stretches the inner button face to the host width.
83
+ * The host is `inline-block`, so `width: 100%` on the host alone leaves the
84
+ * `inline-flex` face shrink-wrapped. Opt in for stacked, full-bleed actions
85
+ * (login cards, sheets, empty states).
86
+ */
87
+ @property({ type: Boolean, reflect: true, attribute: 'full-width' })
88
+ accessor fullWidth: boolean = false;
89
+
81
90
  @property({ type: String, reflect: true })
82
91
  accessor icon!: string;
83
92
 
@@ -124,6 +133,26 @@ export class DeesButton extends DeesElement {
124
133
  display: none;
125
134
  }
126
135
 
136
+ :host([full-width]) {
137
+ display: block;
138
+ width: 100%;
139
+ }
140
+
141
+ /* A full-width button is sized by its container, so its label has to yield rather
142
+ than spill out of the face when the container is narrower than the text. */
143
+ :host([full-width]) .button {
144
+ width: 100%;
145
+ min-width: 0;
146
+ overflow: hidden;
147
+ }
148
+
149
+ :host([full-width]) .textbox {
150
+ min-width: 0;
151
+ overflow: hidden;
152
+ text-overflow: ellipsis;
153
+ white-space: nowrap;
154
+ }
155
+
127
156
  /* Form spacing styles */
128
157
  :host([inside-form]) {
129
158
  margin-bottom: var(--dees-spacing-lg);
@@ -363,10 +392,30 @@ export class DeesButton extends DeesElement {
363
392
  display: none;
364
393
  }
365
394
 
366
- /* Focus state */
395
+ /* Focus state.
396
+ The base .button rule sets outline:none for the pointer path; the ring is restored
397
+ here for :focus-visible only, so a mouse press never leaves a ring behind while
398
+ keyboard focus is always visible. This rule is the focus affordance keyboard users
399
+ actually see — do not remove it alongside the base outline:none. */
367
400
  .button:focus-visible {
368
- outline: 2px solid var(--dees-color-focus-ring);
401
+ /* Deliberately NOT --dees-color-focus-ring: that token is a 0.45/0.55 alpha, which
402
+ composites to 1.85:1 (bright) and 2.31:1 (dark) against the surface behind the
403
+ button — under the 3:1 that WCAG 2.2 SC 1.4.11 requires of a focus indicator, and
404
+ effectively invisible as a navy hairline on a dark canvas. The opaque accent
405
+ measures 4.02:1 bright / 5.76:1 dark. Raising the alpha on the shared token would
406
+ fix every other control too and is the wider change. */
407
+ outline: 2px solid var(--dees-color-accent-primary);
369
408
  outline-offset: 2px;
409
+ /* The indicator has to appear at once. The base .button rule carries
410
+ transition:all, which animates outline-color up from its initial currentColor —
411
+ measured as ~50ms of a near-white ring on a bright accent face, i.e. briefly
412
+ invisible right when the user needs it. Only the focused state opts out. */
413
+ transition: none;
414
+ }
415
+
416
+ /* A disabled face is removed from the tab order, so it should never show a ring. */
417
+ .button.disabled:focus-visible {
418
+ outline: none;
370
419
  }
371
420
 
372
421
  /* Loading spinner */
@@ -488,7 +537,14 @@ export class DeesButton extends DeesElement {
488
537
  class="button ${this.isHidden ? 'hidden' : ''} ${actualType} size-${actualSize} shape-${this.shape} ${this.status} ${this.disabled
489
538
  ? 'disabled'
490
539
  : ''}"
540
+ role="button"
541
+ tabindex=${this.disabled ? -1 : 0}
542
+ aria-disabled=${this.disabled ? 'true' : 'false'}
543
+ aria-busy=${this.status === 'pending' ? 'true' : 'false'}
544
+ .ariaLabel=${showText ? null : this.text || null}
491
545
  @click="${this.dispatchClick}"
546
+ @keydown=${this.handleKeydown}
547
+ @keyup=${this.handleKeyup}
492
548
  >
493
549
  ${this.status === 'normal' ? html``: html`
494
550
  <dees-spinner
@@ -519,6 +575,76 @@ export class DeesButton extends DeesElement {
519
575
  );
520
576
  }
521
577
 
578
+ /** the inner face — the element that actually carries focus */
579
+ private get faceElement(): HTMLElement | null {
580
+ return (this.shadowRoot?.querySelector('.button') as HTMLElement | null) || null;
581
+ }
582
+
583
+ /**
584
+ * States in which the button must not react to input. `disabled` is obvious; the status
585
+ * faces matter too, because `.button.pending / .success / .error` already set
586
+ * `pointer-events: none` and keyboard activation has to refuse the same interactions a
587
+ * mouse cannot make.
588
+ */
589
+ private get isInert(): boolean {
590
+ return this.disabled || this.status !== 'normal';
591
+ }
592
+
593
+ /**
594
+ * Focus lives on the inner face inside the shadow root, so `host.focus()` has to forward
595
+ * or it is a silent no-op. Note that `dees-form-submit.focus()` is deliberately *not*
596
+ * wired to this — that method submits the form, and dees-form depends on it.
597
+ */
598
+ public focus(optionsArg?: FocusOptions): void {
599
+ const face = this.faceElement;
600
+ if (face) {
601
+ face.focus(optionsArg);
602
+ return;
603
+ }
604
+ super.focus(optionsArg);
605
+ }
606
+
607
+ public blur(): void {
608
+ this.faceElement?.blur();
609
+ super.blur();
610
+ }
611
+
612
+ private handleKeydown = (eventArg: KeyboardEvent): void => {
613
+ if (eventArg.key === ' ' || eventArg.key === 'Spacebar') {
614
+ // Native buttons activate Space on keyup. Swallow the keydown so the page cannot
615
+ // scroll underneath the focused button.
616
+ eventArg.preventDefault();
617
+ return;
618
+ }
619
+ if (eventArg.key === 'Enter') {
620
+ eventArg.preventDefault();
621
+ this.activateFromKeyboard();
622
+ }
623
+ };
624
+
625
+ private handleKeyup = (eventArg: KeyboardEvent): void => {
626
+ if (eventArg.key === ' ' || eventArg.key === 'Spacebar') {
627
+ eventArg.preventDefault();
628
+ this.activateFromKeyboard();
629
+ }
630
+ };
631
+
632
+ /**
633
+ * Keyboard activation goes through a real click rather than dispatching `clicked`
634
+ * directly, exactly as a native button does. Several catalog components bind `@click` on
635
+ * dees-button instead of `@clicked` (dees-workspace-diff-editor, the profile picture
636
+ * modal), and both listener styles have to keep working from the keyboard.
637
+ *
638
+ * `pointer-events: none` does not block a programmatic `click()`, so inertness is
639
+ * enforced here rather than left to CSS.
640
+ */
641
+ private activateFromKeyboard(): void {
642
+ if (this.isInert) {
643
+ return;
644
+ }
645
+ this.faceElement?.click();
646
+ }
647
+
522
648
  public async firstUpdated() {
523
649
  // Extract light DOM content (icon + text) and set as properties
524
650
  this.extractLightDom();
@@ -55,6 +55,10 @@ export class DeesSpinner extends DeesElement {
55
55
  display: block;
56
56
  }
57
57
 
58
+ /* --dees-spinner-color lets a spinner on a coloured surface (an accent button face,
59
+ a toast) match that surface's foreground. It is read as an inherited fallback
60
+ rather than declared on :host, because a :host declaration would outrank the
61
+ value an ancestor sets. Unset consumers keep the previous token. */
58
62
  #loading {
59
63
  position: relative;
60
64
  transition: none;
@@ -62,9 +66,14 @@ export class DeesSpinner extends DeesElement {
62
66
  justify-content: center;
63
67
  align-content: center;
64
68
  background: transparent;
65
- border: 3px solid color-mix(in srgb, var(--dees-color-text-primary) 20%, transparent);
69
+ border: 3px solid
70
+ color-mix(
71
+ in srgb,
72
+ var(--dees-spinner-color, var(--dees-color-text-primary)) 20%,
73
+ transparent
74
+ );
66
75
  border-radius: 50%;
67
- border-top-color: var(--dees-color-text-primary);
76
+ border-top-color: var(--dees-spinner-color, var(--dees-color-text-primary));
68
77
  animation: spin 1s ease-in-out infinite;
69
78
  -webkit-animation: spin 1s ease-in-out infinite;
70
79
  }
@@ -47,6 +47,10 @@ export class DeesFormSubmit extends DeesElement {
47
47
  @property({ type: String })
48
48
  accessor iconPosition: 'left' | 'right' = 'left';
49
49
 
50
+ /** stretches the button face to the full host width — see dees-button.fullWidth */
51
+ @property({ type: Boolean, reflect: true, attribute: 'full-width' })
52
+ accessor fullWidth: boolean = false;
53
+
50
54
  constructor() {
51
55
  super();
52
56
  }
@@ -58,6 +62,12 @@ export class DeesFormSubmit extends DeesElement {
58
62
  :host {
59
63
  display: inline-block;
60
64
  }
65
+ /* The host has to stretch too, or forwarding fullWidth only widens the face inside a
66
+ still shrink-wrapped host and the property looks broken. */
67
+ :host([full-width]) {
68
+ display: block;
69
+ width: 100%;
70
+ }
61
71
  dees-button {
62
72
  width: 100%;
63
73
  }
@@ -73,6 +83,7 @@ export class DeesFormSubmit extends DeesElement {
73
83
  .icon=${this.icon}
74
84
  .iconPosition=${this.iconPosition}
75
85
  .text=${this.text}
86
+ .fullWidth=${this.fullWidth}
76
87
  ?disabled=${this.disabled}
77
88
  @clicked=${this.submit}
78
89
  ></dees-button>
@@ -103,6 +114,13 @@ export class DeesFormSubmit extends DeesElement {
103
114
  }
104
115
  }
105
116
 
117
+ /**
118
+ * NOT a focus method despite the name — it submits. `dees-form.addBehaviours()` calls
119
+ * `getSubmitButton()?.focus()` when Enter is pressed in the last field, and that is the
120
+ * catalog's "Enter submits the form" behaviour. Renaming it would break every form, so it
121
+ * stays; keyboard *focus* reaches the submit through the inner dees-button face, which
122
+ * carries its own tabindex.
123
+ */
106
124
  public async focus() {
107
125
  const domtools = await this.domtoolsPromise;
108
126
  if (!this.disabled) {
@@ -67,6 +67,16 @@ export class DeesInputText extends DeesInputBase {
67
67
  @property({ attribute: false })
68
68
  accessor validConfirmed: boolean = false;
69
69
 
70
+ /**
71
+ * autofill hint forwarded to the inner input element.
72
+ * Empty by default, which renders no `autocomplete` attribute at all — byte-identical to
73
+ * this component before the property existed. Set it to a concrete token list to help
74
+ * password managers (`'username'`, `'current-password'`, `'one-time-code'`) or to opt an
75
+ * input into WebAuthn conditional mediation (`'username webauthn'`).
76
+ */
77
+ @property({ type: String })
78
+ accessor autocomplete: string = '';
79
+
70
80
  private validTimeout: ReturnType<typeof setTimeout> | undefined;
71
81
 
72
82
  public static styles = [
@@ -324,6 +334,25 @@ export class DeesInputText extends DeesInputBase {
324
334
  `;
325
335
  }
326
336
 
337
+ /**
338
+ * `autocomplete` is applied imperatively rather than bound in the template so that the
339
+ * default renders no attribute at all. Binding it would emit `autocomplete=""` on every
340
+ * text input in the catalog, and an empty or `on` value is not universally equivalent to
341
+ * an absent one across browser and password-manager heuristics.
342
+ */
343
+ public updated(_changedProperties: Map<string, any>): void {
344
+ super.updated(_changedProperties);
345
+ const input = this.shadowRoot?.querySelector('input');
346
+ if (!input) {
347
+ return;
348
+ }
349
+ if (this.autocomplete) {
350
+ input.setAttribute('autocomplete', this.autocomplete);
351
+ } else {
352
+ input.removeAttribute('autocomplete');
353
+ }
354
+ }
355
+
327
356
  firstUpdated() {
328
357
  if (this.validationFunction && this.value) {
329
358
  const result = this.validationFunction(this.value);