@franzzemen/brokenstock-shell 0.4.1 → 0.4.3

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.
@@ -1865,6 +1865,13 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImpor
1865
1865
  args: [{ providedIn: 'root' }]
1866
1866
  }], ctorParameters: () => [] });
1867
1867
 
1868
+ /**
1869
+ * Elements that mean "the user is typing", where an unmodified shortcut must not fire.
1870
+ *
1871
+ * Kept as a set of tag names plus a `contenteditable` check rather than a
1872
+ * selector string, so it is greppable and so the reasoning stays attached to it.
1873
+ */
1874
+ const TEXT_ENTRY_TAGS = new Set(['INPUT', 'TEXTAREA', 'SELECT']);
1868
1875
  class KeyboardShortcutService {
1869
1876
  #groups;
1870
1877
  #activeGroupId;
@@ -1880,6 +1887,18 @@ class KeyboardShortcutService {
1880
1887
  this.#focusService = inject(TerminalFocusService);
1881
1888
  this.helpVisible = signal(false, /* @ts-ignore */
1882
1889
  ...(ngDevMode ? [{ debugName: "helpVisible" }] : /* istanbul ignore next */ []));
1890
+ /**
1891
+ * True while unmodified shortcuts are being suppressed — the user is typing,
1892
+ * or a dialog is open.
1893
+ *
1894
+ * Exposed because a screen binding BARE keys needs to tell the user whether the
1895
+ * next keystroke acts or types, and that indicator must read the service's own
1896
+ * dispatch state. A screen keeping its own boolean will eventually disagree
1897
+ * with the service, and an indicator that lies about whether a keystroke sells
1898
+ * a thousand shares is worse than no indicator at all.
1899
+ */
1900
+ this.suppressed = signal(false, /* @ts-ignore */
1901
+ ...(ngDevMode ? [{ debugName: "suppressed" }] : /* istanbul ignore next */ []));
1883
1902
  this.activeShortcuts = computed(() => {
1884
1903
  const groupId = this.#activeGroupId();
1885
1904
  if (!groupId)
@@ -1894,11 +1913,44 @@ class KeyboardShortcutService {
1894
1913
  this.#document.addEventListener('keydown', (event) => {
1895
1914
  this.#handleKeydown(event);
1896
1915
  });
1916
+ // ⚠ THE WRITE IS DEFERRED, AND THAT IS NOT AN OPTIMISATION.
1917
+ //
1918
+ // `element.focus()` dispatches `focusin` SYNCHRONOUSLY. Angular calls
1919
+ // `.focus()` from template-driven code — an autofocused input, the shell's
1920
+ // terminal reclaiming focus — so this listener can run in the middle of a
1921
+ // render, and a signal write there throws NG0600, "writing to signals is
1922
+ // not allowed while Angular renders the template".
1923
+ //
1924
+ // That throw shipped: it aborted the login transition, so the sign-in POST
1925
+ // returned 200 and the app still showed "not logged in", with every
1926
+ // authenticated request that followed answering 401. Nothing in the failure
1927
+ // pointed at the keyboard.
1928
+ //
1929
+ // A microtask runs after the render completes, which is soon enough for an
1930
+ // indicator a human reads and late enough to be outside the reactive
1931
+ // context. The value is compared first so an unchanged focus move writes
1932
+ // nothing at all.
1933
+ const refresh = () => {
1934
+ queueMicrotask(() => {
1935
+ const next = this.#isSuppressed();
1936
+ if (next !== this.suppressed())
1937
+ this.suppressed.set(next);
1938
+ });
1939
+ };
1940
+ this.#document.addEventListener('focusin', refresh, true);
1941
+ this.#document.addEventListener('focusout', refresh, true);
1897
1942
  });
1898
1943
  }
1899
1944
  registerGroup(groupId, shortcuts) {
1900
1945
  this.#groups.set(groupId, shortcuts);
1901
1946
  this.#activeGroupId.set(groupId);
1947
+ // Same hazard as the focus listener above: a screen registers from
1948
+ // `ngOnInit`, which Angular may run inside a render.
1949
+ queueMicrotask(() => {
1950
+ const next = this.#isSuppressed();
1951
+ if (next !== this.suppressed())
1952
+ this.suppressed.set(next);
1953
+ });
1902
1954
  }
1903
1955
  unregisterGroup(groupId) {
1904
1956
  this.#groups.delete(groupId);
@@ -1910,17 +1962,61 @@ class KeyboardShortcutService {
1910
1962
  toggleHelp() {
1911
1963
  this.helpVisible.update(v => !v);
1912
1964
  }
1965
+ /**
1966
+ * Would an unmodified shortcut be ignored right now?
1967
+ *
1968
+ * Two cases: a text-entry element has focus, or a dialog is open. Alt
1969
+ * combinations are exempt — they do not collide with typing, which is why
1970
+ * every binding in the app before this guard existed used one.
1971
+ */
1972
+ #isSuppressed() {
1973
+ const active = this.#document.activeElement;
1974
+ if (active) {
1975
+ if (TEXT_ENTRY_TAGS.has(active.tagName))
1976
+ return true;
1977
+ if (active.isContentEditable)
1978
+ return true;
1979
+ }
1980
+ // Angular Material puts every dialog in this container. A shortcut firing
1981
+ // behind an open dialog acts on a screen the user cannot currently see.
1982
+ return this.#document.querySelector('.cdk-overlay-container .cdk-overlay-pane') !== null;
1983
+ }
1984
+ #matches(shortcut, event) {
1985
+ // The physical key when `code` is given, the produced character otherwise.
1986
+ const hit = shortcut.code ? shortcut.code === event.code : shortcut.key === event.key;
1987
+ if (!hit)
1988
+ return false;
1989
+ if (shortcut.modifier === 'Alt')
1990
+ return event.altKey;
1991
+ if (shortcut.modifier === 'Shift') {
1992
+ return event.shiftKey && !event.altKey && !event.ctrlKey && !event.metaKey;
1993
+ }
1994
+ // Unmodified. `ctrl`/`alt`/`meta` are excluded because the page still
1995
+ // receives a keystroke carrying the digit when the browser handles
1996
+ // `ctrl`+1 as a tab switch — without this, switching tabs would also fire
1997
+ // whatever a bare `1` is bound to.
1998
+ return !event.shiftKey && !event.altKey && !event.ctrlKey && !event.metaKey;
1999
+ }
1913
2000
  #handleKeydown(event) {
1914
2001
  const activeId = this.#activeGroupId();
1915
2002
  if (activeId) {
1916
2003
  const shortcuts = this.#groups.get(activeId);
1917
2004
  if (shortcuts) {
1918
- const match = shortcuts.find(s => {
1919
- if (s.modifier === 'Alt')
1920
- return event.altKey && s.key === event.key;
1921
- return !event.altKey && !event.ctrlKey && !event.metaKey && s.key === event.key;
1922
- });
2005
+ const match = shortcuts.find(s => this.#matches(s, event));
1923
2006
  if (match) {
2007
+ const alt = match.modifier === 'Alt';
2008
+ // Alt bindings are exempt from both guards below, so nothing that
2009
+ // existed before them changes behaviour: Alt does not collide with
2010
+ // typing, and holding Alt+ArrowDown to walk a list is wanted.
2011
+ if (!alt) {
2012
+ // A key held down repeats at roughly thirty a second. On a screen
2013
+ // where a bare key places an order, that is fifteen orders in half a
2014
+ // second, and a synchronous action path will run every one of them.
2015
+ if (event.repeat)
2016
+ return false;
2017
+ if (this.#isSuppressed())
2018
+ return false;
2019
+ }
1924
2020
  event.preventDefault();
1925
2021
  this.#zone.run(() => match.action());
1926
2022
  return true;