Repository: Thinkmill/keystatic (ts)

Issue:
Entry: save shortcut
When using Keystatic, a number of times I've pressed <kbd>⌘S</kbd> expecting it to save, instead my browser prompts me to save the HTML page which I think is *never* what you want…

We should support the <kbd>Meta+S</kbd> keyboard shortcut to save the current entry (Meta: `CMD` on MacOS, `CTRL` on Windows)

Pull request description:
Front end tweaks
Resolves #532

- ListViewItem: always show drag handles
- ListViewItem: background change only on hover (not focus)
- TableRow: background change only on hover (not focus)
- CollectionPage: "pointer" cursor on rows
- Dashboard cards: no underline / background hover
- Remove unused `flattenChildren` utilities
- Support "meta+s" shortcut to save entry

Patch:
diff --git a/.changeset/rude-forks-chew.md b/.changeset/rude-forks-chew.md
new file mode 100644
index 00000000..3b278497
--- /dev/null
+++ b/.changeset/rude-forks-chew.md
@@ -0,0 +1,8 @@
+---
+'@keystatic/core': patch
+'@keystar/ui': patch
+---
+
+- list-item + table-row: background change only on hover (not focus)
+- allow shortcuts "cmd+s" and "ctrl+s" to save entry
+- remove unused `flattenChildren` utility
diff --git a/design-system/pkg/package.json b/design-system/pkg/package.json
index fcd47fed..4f878069 100644
--- a/design-system/pkg/package.json
+++ b/design-system/pkg/package.json
@@ -6205,7 +6205,6 @@
     "emery": "^1.4.1",
     "facepaint": "^1.2.1",
     "lodash": "^4.17.21",
-    "react-keyed-flatten-children": "^1.3.0",
     "react-transition-group": "^4.4.5"
   },
   "devDependencies": {
diff --git a/design-system/pkg/src/list-view/ListViewItem.tsx b/design-system/pkg/src/list-view/ListViewItem.tsx
index 611fcc7a..fede9c96 100644
--- a/design-system/pkg/src/list-view/ListViewItem.tsx
+++ b/design-system/pkg/src/list-view/ListViewItem.tsx
@@ -296,7 +296,7 @@ export function ListViewItem<T>(props: ListViewItemProps<T>) {
             },
 
             // interactions
-            '&[data-focus], &[data-interaction="hover"]': {
+            '&[data-interaction="hover"]': {
               backgroundColor: tokenSchema.color.alias.backgroundHovered,
             },
             '&[data-interaction="press"]': {
@@ -384,7 +384,6 @@ export function ListViewItem<T>(props: ListViewItemProps<T>) {
                       }),
                       'ksv-list-view-item-draghandle-button'
                     )}
-                    style={!isFocusVisibleWithin ? { opacity: 0 } : {}}
                     ref={dragButtonRef}
                     draggable="true"
                   >
diff --git a/design-system/pkg/src/table/styles.tsx b/design-system/pkg/src/table/styles.tsx
index d12a9550..3d9ac817 100644
--- a/design-system/pkg/src/table/styles.tsx
+++ b/design-system/pkg/src/table/styles.tsx
@@ -262,11 +262,11 @@ export function useRowStyleProps(state: {
     },
 
     // interactions
-    '&[data-focus], &[data-interaction="hover"]': {
-      backgroundColor: tokenSchema.color.alias.backgroundHovered,
+    '&[data-interaction="hover"]': {
+      backgroundColor: tokenSchema.color.alias.backgroundIdle,
     },
     '&[data-interaction="press"]': {
-      backgroundColor: tokenSchema.color.alias.backgroundPressed,
+      backgroundColor: tokenSchema.color.alias.backgroundHovered,
     },
 
     // focus indicator
@@ -288,7 +288,7 @@ export function useRowStyleProps(state: {
       backgroundColor: tokenSchema.color.alias.backgroundSelected,
       // boxShadow: `0 0 0 ${tokenSchema.size.border.regular} ${tokenSchema.color.alias.focusRing}`,
 
-      '&[data-interaction="hover"], &[data-focus="visible"]': {
+      '&[data-interaction="hover"]': {
         backgroundColor: tokenSchema.color.alias.backgroundSelectedHovered,
       },
     },
diff --git a/design-system/pkg/src/utils/flattenChildren.ts b/design-system/pkg/src/utils/flattenChildren.ts
deleted file mode 100644
index 5346927f..00000000
--- a/design-system/pkg/src/utils/flattenChildren.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import { ReactNode, isValidElement } from 'react';
-import _flattenChildren from 'react-keyed-flatten-children';
-
-export function flattenChildren(children: ReactNode) {
-  return _flattenChildren(children);
-}
-
-export function flattenElements(children: ReactNode) {
-  return _flattenChildren(children).filter(isValidElement);
-}
diff --git a/design-system/pkg/src/utils/index.ts b/design-system/pkg/src/utils/index.ts
index 4efb7c9d..74d7340a 100644
--- a/design-system/pkg/src/utils/index.ts
+++ b/design-system/pkg/src/utils/index.ts
@@ -1,7 +1,6 @@
 export { cloneValidElement, useHasChild } from './children';
 export { composeId } from './composeId';
 export { isReactText } from './guards';
-export { flattenChildren, flattenElements } from './flattenChildren';
 export { toDataAttributes } from './toDataAttributes';
 export { usePrevious } from './usePrevious';
 export { useId } from './useId';
diff --git a/packages/keystatic/src/app/CollectionPage.tsx b/packages/keystatic/src/app/CollectionPage.tsx
index cb0f94d2..d780256e 100644
--- a/packages/keystatic/src/app/CollectionPage.tsx
+++ b/packages/keystatic/src/app/CollectionPage.tsx
@@ -1,8 +1,6 @@
 import { useLocalizedStringFormatter } from '@react-aria/i18n';
 import React, { Key, useEffect, useMemo, useRef, useState } from 'react';
 
-import { Badge } from '@keystar/ui/badge';
-import { Breadcrumbs, Item } from '@keystar/ui/breadcrumbs';
 import { ActionButton, Button } from '@keystar/ui/button';
 import { Icon } from '@keystar/ui/icon';
 import { alertCircleIcon } from '@keystar/ui/icon/icons/alertCircleIcon';
@@ -12,6 +10,7 @@ import { searchXIcon } from '@keystar/ui/icon/icons/searchXIcon';
 import { TextLink } from '@keystar/ui/link';
 import { ProgressCircle } from '@keystar/ui/progress';
 import { SearchField } from '@keystar/ui/search-field';
+import { StatusLight } from '@keystar/ui/status-light';
 import {
   breakpointQueries,
   css,
@@ -27,6 +26,7 @@ import {
   Row,
   SortDescriptor,
 } from '@keystar/ui/table';
+import { Heading, Text } from '@keystar/ui/typography';
 
 import { Config } from '../config';
 import { sortByDescriptor } from './collection-sort';
@@ -86,9 +86,9 @@ function CollectionPageHeader(props: {
 
   return (
     <PageHeader>
-      <Breadcrumbs size="medium" flex minWidth={0}>
-        <Item key="collection">{collectionLabel}</Item>
-      </Breadcrumbs>
+      <Heading elementType="h1" id="page-title" size="small" flex minWidth={0}>
+        {collectionLabel}
+      </Heading>
       <div
         role="search"
         style={{
@@ -292,6 +292,10 @@ function CollectionTable(
         [breakpointQueries.above.tablet]: {
           marginInline: `calc(${tokenSchema.size.space.xxlarge} - ${tokenSchema.size.space.medium})`,
         },
+
+        '[role=rowheader]': {
+          cursor: 'pointer',
+        },
       })}
     >
       <TableHeader
@@ -315,14 +319,12 @@ function CollectionTable(
         {item => (
           <Row key={item.name}>
             <Cell textValue={item.name}>
-              <TextLink
-                href={getItemPath(props.basePath, props.collection, item.name)}
-              >
-                {item.name}
-              </TextLink>
+              <Text weight="medium">{item.name}</Text>
             </Cell>
             <Cell textValue={item.status}>
-              <Badge tone={statusTones[item.status]}>{item.status}</Badge>
+              <StatusLight tone={statusTones[item.status]}>
+                {item.status}
+              </StatusLight>
             </Cell>
           </Row>
         )}
diff --git a/packages/keystatic/src/app/ItemPage.tsx b/packages/keystatic/src/app/ItemPage.tsx
index 34f09171..6d759021 100644
--- a/packages/keystatic/src/app/ItemPage.tsx
+++ b/packages/keystatic/src/app/ItemPage.tsx
@@ -1,11 +1,14 @@
 import { useLocalizedStringFormatter } from '@react-aria/i18n';
 import { assert } from 'emery';
+import isHotkey from 'is-hotkey';
 import {
   FormEvent,
   Key,
   PropsWithChildren,
   ReactElement,
   ReactNode,
+  useCallback,
+  useEffect,
   useMemo,
   useState,
 } from 'react';
@@ -172,7 +175,7 @@ function ItemPage(props: ItemPageProps) {
     }
   };
 
-  const onUpdate = async () => {
+  const onUpdate = useCallback(async () => {
     if (!clientSideValidateProp(schema, state, props.slugInfo)) {
       setForceValidation(true);
       return;
@@ -186,9 +189,34 @@ function ItemPage(props: ItemPageProps) {
         )}/item/${encodeURIComponent(slug)}`
       );
     }
-  };
+  }, [
+    collection,
+    collectionConfig,
+    itemSlug,
+    props.basePath,
+    props.slugInfo,
+    router,
+    schema,
+    state,
+    update,
+  ]);
   const formID = 'item-edit-form';
 
+  // allow shortcuts "cmd+s" and "ctrl+s" to save
+  useEffect(() => {
+    const listener = (event: KeyboardEvent) => {
+      if (updateResult.kind === 'loading') {
+        return;
+      }
+      if (isHotkey('mod+s', event)) {
+        event.preventDefault();
+        onUpdate();
+      }
+    };
+    document.addEventListener('keydown', listener);
+    return () => document.removeEventListener('keydown', listener);
+  }, [updateResult.kind, onUpdate]);
+
   return (
     <>
       <ItemPageShell
diff --git a/packages/keystatic/src/app/dashboard/SingletonSection.tsx b/packages/keystatic/src/app/dashboard/SingletonSection.tsx
index 5c30964d..b869c26c 100644
--- a/packages/keystatic/src/app/dashboard/SingletonSection.tsx
+++ b/packages/keystatic/src/app/dashboard/SingletonSection.tsx
@@ -9,6 +9,10 @@ export function SingletonSection(props: { config: Config; basePath: string }) {
   let changed = useChanged();
   let singletons = keyedEntries(props.config.singletons ?? {});
 
+  if (singletons.length === 0) {
+    return null;
+  }
+
   return (
     <DashboardSection title="Singletons">
       <DashboardGrid>
diff --git a/packages/keystatic/src/app/dashboard/components.tsx b/packages/keystatic/src/app/dashboard/components.tsx
index bb4269b0..bccc74de 100644
--- a/packages/keystatic/src/app/dashboard/components.tsx
+++ b/packages/keystatic/src/app/dashboard/components.tsx
@@ -2,7 +2,13 @@ import { PropsWithChildren, ReactElement } from 'react';
 
 import { Flex } from '@keystar/ui/layout';
 import { useLinkComponent } from '@keystar/ui/link';
-import { containerQueries, css, tokenSchema } from '@keystar/ui/style';
+import {
+  classNames,
+  containerQueries,
+  css,
+  tokenSchema,
+  transition,
+} from '@keystar/ui/style';
 import { Heading, Text } from '@keystar/ui/typography';
 
 export const DashboardSection = ({
@@ -65,41 +71,41 @@ export const DashboardCard = (
         <Heading elementType="h3" size="small" truncate>
           <Link
             href={props.href}
-            className={css({
-              color: tokenSchema.color.foreground.neutral,
-              outline: 'none',
-              textDecoration: 'underline',
-              textDecorationColor: 'transparent',
-              textDecorationThickness: tokenSchema.size.border.regular,
-              textUnderlineOffset: tokenSchema.size.border.medium,
+            className={classNames(
+              css({
+                color: tokenSchema.color.foreground.neutral,
+                outline: 'none',
 
-              '&:hover': {
-                color: tokenSchema.color.foreground.neutralEmphasis,
-                textDecorationColor: tokenSchema.color.foreground.neutral,
+                '&:hover': {
+                  color: tokenSchema.color.foreground.neutralEmphasis,
 
-                '::before': {
-                  borderColor: tokenSchema.color.border.emphasis,
+                  '::before': {
+                    backgroundColor: tokenSchema.color.alias.backgroundIdle,
+                    borderColor: tokenSchema.color.border.neutral,
+                  },
                 },
-              },
-              '&:active': {
-                '::before': {
-                  borderColor: tokenSchema.color.background.accentEmphasis,
+                '&:active': {
+                  '::before': {
+                    backgroundColor: tokenSchema.color.alias.backgroundHovered,
+                    borderColor: tokenSchema.color.alias.borderHovered,
+                  },
+                },
+                '&:focus-visible::before': {
+                  outline: `${tokenSchema.size.alias.focusRing} solid ${tokenSchema.color.alias.focusRing}`,
+                  outlineOffset: tokenSchema.size.alias.focusRingGap,
                 },
-              },
-              '&:focus-visible::before': {
-                outline: `${tokenSchema.size.alias.focusRing} solid ${tokenSchema.color.alias.focusRing}`,
-                outlineOffset: tokenSchema.size.alias.focusRingGap,
-              },
 
-              // fill the available space so that the card is clickable
-              '::before': {
-                border: `${tokenSchema.size.border.regular} solid ${tokenSchema.color.border.muted}`,
-                borderRadius: tokenSchema.size.radius.medium,
-                content: '""',
-                position: 'absolute',
-                inset: 0,
-              },
-            })}
+                // fill the available space so that the card is clickable
+                '::before': {
+                  border: `${tokenSchema.size.border.regular} solid ${tokenSchema.color.border.muted}`,
+                  borderRadius: tokenSchema.size.radius.medium,
+                  content: '""',
+                  position: 'absolute',
+                  inset: 0,
+                  transition: transition(['background-color', 'border-color']),
+                },
+              })
+            )}
           >
             {props.label}
           </Link>
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 51ced873..16bdf2fa 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -267,7 +267,6 @@ importers:
       next: ^13.4.8
       react: ^18.2.0
       react-dom: ^18.2.0
-      react-keyed-flatten-children: ^1.3.0
       react-transition-group: ^4.4.5
       tsx: ^3.8.0
     dependencies:
@@ -350,7 +349,6 @@ importers:
       emery: 1.4.1
       facepaint: 1.2.1
       lodash: 4.17.21
-      react-keyed-flatten-children: 1.3.0_react@18.2.0
       react-transition-group: 4.4.5_biqbaboplfbrettd7655fr4n2y
     devDependencies:
       '@keystar/ui': 'link:'


Test patch:
diff --git a/design-system/pkg/src/utils/test/isReactText.test.tsx b/design-system/pkg/src/utils/test/isReactText.test.tsx
index f1b68c9c..300ba48f 100644
--- a/design-system/pkg/src/utils/test/isReactText.test.tsx
+++ b/design-system/pkg/src/utils/test/isReactText.test.tsx
@@ -1,17 +1,4 @@
-import { Fragment } from 'react';
-
-import { flattenChildren, isReactText } from '@keystar/ui/utils';
-
-let fragmentOfReactText = (
-  <Fragment>
-    {true && 'test'}
-    {false}
-    {null}
-    {true && 123}
-    {undefined}
-    {0 ?? 456}
-  </Fragment>
-);
+import { isReactText } from '@keystar/ui/utils';
 
 describe('utils/isReactText', () => {
   it('string', () => {
@@ -33,21 +20,4 @@ describe('utils/isReactText', () => {
     expect(isReactText({ a: 1, b: 2 })).toEqual(false);
     expect(isReactText(undefined)).toEqual(false);
   });
-
-  it('should work with flattenChildren', () => {
-    expect(isReactText(flattenChildren(fragmentOfReactText))).toEqual(true);
-    expect(
-      isReactText(
-        flattenChildren(
-          <Fragment>
-            {flattenChildren(fragmentOfReactText)}
-            <Fragment>
-              {flattenChildren(fragmentOfReactText)}
-              {flattenChildren(fragmentOfReactText)}
-            </Fragment>
-          </Fragment>
-        )
-      )
-    ).toEqual(true);
-  });
 });