Repository: Thinkmill/keystatic
Pull request #490 (2023-07-27 07:26:36)

Cloud UI requirements
A few updates and additions for work on the cloud app

changeset-bot[bot]: ###  ⚠️  No Changeset found

Latest commit: cef123c7cc4252bba370af600f2248bec43f7e57

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. **If these changes should result in a version bump, you need to add a changeset.**

<details><summary>This PR includes no changesets</summary>

  When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

</details>

[Click here to learn what changesets are, and how to add one](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add a changeset to this PR](https://github.com/Thinkmill/keystatic/new/cloud-ui-requirements?filename=.changeset/soft-peas-brake.md&value=---%0A%22%40keystar%2Fui%22%3A%20patch%0A%22keystatic-docs%22%3A%20patch%0A---%0A%0ACloud%20UI%20requirements%0A)


vercel[bot]: [vc]: #IeLSijYKEX51/OIKQACJWPVDkf3mqseHnatKyX/4NPQ=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJrZXlzdGF0aWMiLCJyb290RGlyZWN0b3J5IjoiZGV2LXByb2plY3RzL25leHQtYXBwIiwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL3RoaW5rbWlsbC1sYWJzL2tleXN0YXRpYy9DSHBrSEhrNTNScEZqQ0JLTHJzbWtSaFpnQW1zIiwicHJldmlld1VybCI6ImtleXN0YXRpYy1naXQtY2xvdWQtdWktcmVxdWlyZW1lbnRzLXRoaW5rbWlsbC1sYWJzLnZlcmNlbC5hcHAiLCJuZXh0Q29tbWl0U3RhdHVzIjoiREVQTE9ZRUQifSx7Im5hbWUiOiJrZXlzdGFyLXVpIiwicm9vdERpcmVjdG9yeSI6ImRlc2lnbi1zeXN0ZW0vZG9jcyIsImluc3BlY3RvclVybCI6Imh0dHBzOi8vdmVyY2VsLmNvbS90aGlua21pbGwtbGFicy9rZXlzdGFyLXVpL0dIZXpXeGl5bzVDWFJjZTFlQjROZ0tycHd5NmkiLCJuZXh0Q29tbWl0U3RhdHVzIjoiUEVORElORyJ9LHsibmFtZSI6ImtleXN0YXRpYy1zaXRlIiwicm9vdERpcmVjdG9yeSI6ImRvY3MiLCJpbnNwZWN0b3JVcmwiOiJodHRwczovL3ZlcmNlbC5jb20vdGhpbmttaWxsLWxhYnMva2V5c3RhdGljLXNpdGUvSDNRODllcVM1RFloeWY0cWdCZnV0ODRaRXZjdiIsIm5leHRDb21taXRTdGF0dXMiOiJQRU5ESU5HIn1dfQ==
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Updated (UTC) |
| :--- | :----- | :------ | :------ |
| **keystar-ui** | 🔄 Building ([Inspect](https://vercel.com/thinkmill-labs/keystar-ui/GHezWxiyo5CXRce1eB4NgKrpwy6i)) |  | Jul 27, 2023 7:26am |
| **keystatic** | ✅ Ready ([Inspect](https://vercel.com/thinkmill-labs/keystatic/CHpkHHk53RpFjCBKLrsmkRhZgAms)) | [Visit Preview](https://keystatic-git-cloud-ui-requirements-thinkmill-labs.vercel.app) | Jul 27, 2023 7:26am |
| **keystatic-site** | 🔄 Building ([Inspect](https://vercel.com/thinkmill-labs/keystatic-site/H3Q89eqS5DYhyf4qgBfut84ZEvcv)) |  | Jul 27, 2023 7:26am |





Patch:
diff --git a/.changeset/early-paws-shout.md b/.changeset/early-paws-shout.md
new file mode 100644
index 00000000..c5d149cf
--- /dev/null
+++ b/.changeset/early-paws-shout.md
@@ -0,0 +1,10 @@
+---
+'@keystar/ui': patch
+---
+
+A few updates and additions for work on the cloud app:
+
+- expose `HStack` and `VStack` components from the "layout" package
+- adjust `Tray` appearance on larger screens
+- fix `Button` icon appearance
+- expose `StatusLight` component from new "status-light" package
\ No newline at end of file
diff --git a/design-system/pkg/package.json b/design-system/pkg/package.json
index 9e16fbfc..629a9e54 100644
--- a/design-system/pkg/package.json
+++ b/design-system/pkg/package.json
@@ -238,6 +238,11 @@
       "module": "./search-field/dist/keystar-ui-search-field.esm.js",
       "default": "./search-field/dist/keystar-ui-search-field.cjs.js"
     },
+    "./status-light": {
+      "types": "./status-light/dist/keystar-ui-status-light.cjs.js",
+      "module": "./status-light/dist/keystar-ui-status-light.esm.js",
+      "default": "./status-light/dist/keystar-ui-status-light.cjs.js"
+    },
     "./drag-and-drop": {
       "types": "./drag-and-drop/dist/keystar-ui-drag-and-drop.cjs.js",
       "module": "./drag-and-drop/dist/keystar-ui-drag-and-drop.esm.js",
diff --git a/design-system/pkg/src/badge/Badge.tsx b/design-system/pkg/src/badge/Badge.tsx
index 9fd22949..b6a7ca20 100644
--- a/design-system/pkg/src/badge/Badge.tsx
+++ b/design-system/pkg/src/badge/Badge.tsx
@@ -39,9 +39,6 @@ export type BadgeProps = {
 /**
  * A badge is a decorative indicator used to either call attention to an item or
  * for communicating non-actionable, supplemental information.
- *
- * Use badges to highlight an item's status for quick recognition. Badges should
- * contain short easy-to-scan text.
  */
 export const Badge: ForwardRefExoticComponent<
   BadgeProps & { ref?: Ref<HTMLDivElement> }
diff --git a/design-system/pkg/src/button/Button.tsx b/design-system/pkg/src/button/Button.tsx
index ec322316..8ba621e2 100644
--- a/design-system/pkg/src/button/Button.tsx
+++ b/design-system/pkg/src/button/Button.tsx
@@ -134,7 +134,6 @@ export const useButtonChildren = (props: CommonButtonProps) => {
     return {
       icon: {
         UNSAFE_className: buttonClassList.declare('icon'),
-        strokeScaling: false, // bump up the stroke width to match the font-weight
       },
       text: {
         color: 'inherit',
diff --git a/design-system/pkg/src/button/useButtonStyles.tsx b/design-system/pkg/src/button/useButtonStyles.tsx
index 8e5c81c3..89460116 100644
--- a/design-system/pkg/src/button/useButtonStyles.tsx
+++ b/design-system/pkg/src/button/useButtonStyles.tsx
@@ -49,7 +49,7 @@ export function useButtonStyles(props: ButtonProps, state: ButtonState) {
         justifyContent: 'center',
         minWidth: tokenSchema.size.element.regular,
         outline: 0,
-        gap: tokenSchema.size.space.small,
+        gap: tokenSchema.size.space.regular,
         paddingInline: tokenSchema.size.space.large,
         position: 'relative',
         transitionDuration: '130ms',
@@ -63,11 +63,17 @@ export function useButtonStyles(props: ButtonProps, state: ButtonState) {
           cursor: 'default',
         },
 
-        // contents
+        // inherit text styles from parent
         [buttonClassList.childSelector('text')]: {
           fontSize: 'inherit',
           fontWeight: 'inherit',
         },
+        // special size for button icons. otherwise they appear too "thin"
+        // beside the bold text
+        [buttonClassList.childSelector('icon')]: {
+          height: tokenSchema.size.scale[225],
+          width: tokenSchema.size.scale[225],
+        },
 
         // focus ring
         '--focus-ring-color': tokenSchema.color.alias.focusRing,
diff --git a/design-system/pkg/src/layout/Stack.tsx b/design-system/pkg/src/layout/Stack.tsx
new file mode 100644
index 00000000..09f076b4
--- /dev/null
+++ b/design-system/pkg/src/layout/Stack.tsx
@@ -0,0 +1,52 @@
+import { ReactNode } from 'react';
+
+import { filterStyleProps, FlexStyleProps } from '@keystar/ui/style';
+import { forwardRefWithAs } from '@keystar/ui/utils/ts';
+
+import { flexStyleProps, useFlexStyleProps } from './resolvers';
+
+export type StackProps = {
+  children?: ReactNode;
+} & Omit<FlexStyleProps, 'direction' | 'inline' | 'wrap'>;
+
+/** A thin wrapper around `Flex`, for stacking elements vertically. */
+export const VStack = forwardRefWithAs<StackProps, 'div'>(
+  (props, forwardedRef) => {
+    const { elementType: ElementType = 'div', children, ...otherProps } = props;
+    const styleProps = useFlexStyleProps({
+      direction: 'column',
+      ...otherProps,
+    });
+
+    return (
+      <ElementType
+        ref={forwardedRef}
+        {...filterStyleProps(otherProps, Object.keys(flexStyleProps))}
+        {...styleProps}
+      >
+        {children}
+      </ElementType>
+    );
+  }
+);
+
+/** A thin wrapper around `Flex`, for stacking elements horizontally. */
+export const HStack = forwardRefWithAs<StackProps, 'div'>(
+  (props, forwardedRef) => {
+    const { elementType: ElementType = 'div', children, ...otherProps } = props;
+    const styleProps = useFlexStyleProps({
+      direction: 'row',
+      ...otherProps,
+    });
+
+    return (
+      <ElementType
+        ref={forwardedRef}
+        {...filterStyleProps(otherProps, Object.keys(flexStyleProps))}
+        {...styleProps}
+      >
+        {children}
+      </ElementType>
+    );
+  }
+);
diff --git a/design-system/pkg/src/layout/index.ts b/design-system/pkg/src/layout/index.ts
index 3a6fd039..ddf7b40c 100644
--- a/design-system/pkg/src/layout/index.ts
+++ b/design-system/pkg/src/layout/index.ts
@@ -5,6 +5,7 @@ export { Box } from './Box';
 export { Divider } from './Divider';
 export { Flex } from './Flex';
 export { Grid } from './Grid';
+export { HStack, VStack } from './Stack';
 export { Surface, useSurface } from './Surface';
 export {
   fitContent,
diff --git a/design-system/pkg/src/overlays/Tray.tsx b/design-system/pkg/src/overlays/Tray.tsx
index 57db0e32..51c7ef4a 100644
--- a/design-system/pkg/src/overlays/Tray.tsx
+++ b/design-system/pkg/src/overlays/Tray.tsx
@@ -10,6 +10,8 @@ import { useModalOverlay } from '@react-aria/overlays';
 import { useObjectRef, useViewportSize } from '@react-aria/utils';
 
 import {
+  breakpointQueries,
+  breakpoints,
   classNames,
   css,
   tokenSchema,
@@ -71,7 +73,6 @@ let TrayWrapper = forwardRef(function TrayWrapper(
   // making the viewport resize. The visual viewport also shrinks when the
   // keyboard is displayed.
   let viewport = useViewportSize();
-  let maxWidth = 440;
 
   // Attach Transition's nodeRef to outer most wrapper for node.reflow:
   // https://github.com/reactjs/react-transition-group/blob/c89f807067b32eea6f68fd6c622190d88ced82e2/src/Transition.js#L231
@@ -129,11 +130,11 @@ let TrayWrapper = forwardRef(function TrayWrapper(
               zIndex: 2, // above blanket
 
               // NOTE: trays shouldn't be used for larger screens, but in case
-              // they do we need to tweak the appearance.
-              [`@media (min-width: ${maxWidth}px)`]: {
+              // they are we need to tweak the appearance.
+              [breakpointQueries.above.mobile]: {
                 borderStartStartRadius: tokenSchema.size.radius.medium, // TODO: component token?
                 borderStartEndRadius: tokenSchema.size.radius.medium, // TODO: component token?
-                maxWidth,
+                maxWidth: breakpoints.tablet,
               },
 
               // exit animation
diff --git a/design-system/pkg/src/status-light/StatusLight.tsx b/design-system/pkg/src/status-light/StatusLight.tsx
new file mode 100644
index 00000000..7160df10
--- /dev/null
+++ b/design-system/pkg/src/status-light/StatusLight.tsx
@@ -0,0 +1,116 @@
+import { AriaLabelingProps, DOMProps } from '@react-types/shared';
+import { filterDOMProps } from '@react-aria/utils';
+import {
+  ReactNode,
+  forwardRef,
+  ForwardedRef,
+  ForwardRefExoticComponent,
+  Ref,
+} from 'react';
+
+import {
+  BaseStyleProps,
+  useStyleProps,
+  tokenSchema,
+  css,
+} from '@keystar/ui/style';
+import { Text } from '@keystar/ui/typography';
+import { isReactText } from '@keystar/ui/utils';
+
+type StatusLightTones =
+  | 'accent'
+  | 'caution'
+  | 'critical'
+  | 'neutral'
+  | 'pending'
+  | 'positive';
+
+export type StatusLightProps = {
+  /** The content to display as the label. */
+  children?: ReactNode;
+  /**
+   * An accessibility role for the status light. Should be set when the status
+   * can change at runtime, and no more than one status light will update simultaneously.
+   */
+  role?: 'status';
+  /**
+   * The tone of the status light, which indicates semantic meaning.
+   * @default 'neutral'
+   */
+  tone?: StatusLightTones;
+} & BaseStyleProps &
+  DOMProps &
+  AriaLabelingProps;
+
+/** Status lights describe the state or condition of an entity. */
+export const StatusLight: ForwardRefExoticComponent<
+  StatusLightProps & { ref?: Ref<HTMLDivElement> }
+> = forwardRef(function StatusLight(
+  props: StatusLightProps,
+  forwardedRef: ForwardedRef<HTMLDivElement>
+) {
+  let { children, role, tone = 'neutral' } = props;
+  const styleProps = useStyleProps(props);
+
+  if (!children && !props['aria-label']) {
+    console.warn(
+      'If no children are provided, an aria-label must be specified'
+    );
+  }
+
+  if (!role && (props['aria-label'] || props['aria-labelledby'])) {
+    console.warn('A labelled StatusLight must have a role.');
+  }
+
+  return (
+    <div
+      {...filterDOMProps(props)}
+      {...styleProps}
+      ref={forwardedRef}
+      data-tone={tone}
+      className={css({
+        alignItems: 'center',
+        color: tokenSchema.color.foreground.neutral,
+        display: 'flex',
+        gap: tokenSchema.size.space.regular,
+        height: tokenSchema.size.element.small,
+
+        // indicator
+        '&::before': {
+          content: '""',
+          backgroundColor: tokenSchema.color.foreground.neutralTertiary,
+          borderRadius: tokenSchema.size.radius.full,
+          height: tokenSchema.size.scale[100],
+          width: tokenSchema.size.scale[100],
+        },
+
+        // special case for neutral
+        '&[data-tone=neutral]': {
+          color: tokenSchema.color.foreground.neutralSecondary,
+        },
+
+        '&[data-tone=accent]::before': {
+          backgroundColor: tokenSchema.color.background.accentEmphasis,
+        },
+        '&[data-tone=caution]::before': {
+          backgroundColor: tokenSchema.color.background.cautionEmphasis,
+        },
+        '&[data-tone=critical]::before': {
+          backgroundColor: tokenSchema.color.background.criticalEmphasis,
+        },
+        '&[data-tone=pending]::before': {
+          backgroundColor: tokenSchema.color.background.pendingEmphasis,
+        },
+        '&[data-tone=positive]::before': {
+          backgroundColor: tokenSchema.color.background.positiveEmphasis,
+        },
+      })}
+    >
+      {isReactText(children) ? (
+        <Text color="inherit">{children}</Text>
+      ) : (
+        children
+      )}
+    </div>
+  );
+});
diff --git a/design-system/pkg/src/status-light/docs/index.md b/design-system/pkg/src/status-light/docs/index.md
new file mode 100644
index 00000000..edda1f52
--- /dev/null
+++ b/design-system/pkg/src/status-light/docs/index.md
@@ -0,0 +1,36 @@
+---
+title: StatusLight
+description: Status lights describe the state or condition of an entity.
+category: Feedback
+---
+
+## Example
+
+```jsx {% live=true %}
+<StatusLight>Status light</StatusLight>
+```
+
+## Props
+
+### Tone
+
+Use the `tone` prop to indicate the semantic meaning of the status light.
+
+```jsx {% live=true %}
+<Flex gap="large" wrap>
+  <StatusLight tone="neutral">Neutral</StatusLight>
+  <StatusLight tone="accent">Accent</StatusLight>
+  <StatusLight tone="positive">Positive</StatusLight>
+  <StatusLight tone="caution">Caution</StatusLight>
+  <StatusLight tone="critical">Critical</StatusLight>
+  <StatusLight tone="pending">Pending</StatusLight>
+</Flex>
+```
+
+### Label
+
+If no children are provided, an `aria-label` must be specified.
+
+```tsx {% live=true %}
+<StatusLight aria-label="Approved" tone="positive" role="status" />
+```
diff --git a/design-system/pkg/src/status-light/index.ts b/design-system/pkg/src/status-light/index.ts
new file mode 100644
index 00000000..5611fc48
--- /dev/null
+++ b/design-system/pkg/src/status-light/index.ts
@@ -0,0 +1,5 @@
+'use client';
+
+export { StatusLight } from './StatusLight';
+
+export type { StatusLightProps } from './StatusLight';
diff --git a/design-system/pkg/src/status-light/stories/StatusLight.stories.tsx b/design-system/pkg/src/status-light/stories/StatusLight.stories.tsx
new file mode 100644
index 00000000..577fa44c
--- /dev/null
+++ b/design-system/pkg/src/status-light/stories/StatusLight.stories.tsx
@@ -0,0 +1,36 @@
+import { Flex } from '@keystar/ui/layout';
+
+import { StatusLight } from '..';
+
+export default {
+  title: 'Components/StatusLight',
+};
+
+export const Default = () => <StatusLight>Status light</StatusLight>;
+
+Default.story = {
+  name: 'default',
+};
+
+export const NoVisibleLabel = () => (
+  <StatusLight aria-label="Approved" tone="positive" role="status" />
+);
+
+NoVisibleLabel.story = {
+  name: 'no visible label',
+};
+
+export const Tones = () => (
+  <Flex gap="large" padding="large" wrap>
+    <StatusLight tone="neutral">Neutral</StatusLight>
+    <StatusLight tone="accent">Accent</StatusLight>
+    <StatusLight tone="positive">Positive</StatusLight>
+    <StatusLight tone="caution">Caution</StatusLight>
+    <StatusLight tone="critical">Critical</StatusLight>
+    <StatusLight tone="pending">Pending</StatusLight>
+  </Flex>
+);
+
+Tones.story = {
+  name: 'tones',
+};
diff --git a/design-system/pkg/status-light/package.json b/design-system/pkg/status-light/package.json
new file mode 100644
index 00000000..d8251330
--- /dev/null
+++ b/design-system/pkg/status-light/package.json
@@ -0,0 +1,4 @@
+{
+  "main": "dist/keystar-ui-status-light.cjs.js",
+  "module": "dist/keystar-ui-status-light.esm.js"
+}


Test patch:
diff --git a/design-system/pkg/src/status-light/test/StatusLight.tsx b/design-system/pkg/src/status-light/test/StatusLight.tsx
new file mode 100644
index 00000000..8f57be96
--- /dev/null
+++ b/design-system/pkg/src/status-light/test/StatusLight.tsx
@@ -0,0 +1,23 @@
+import '@testing-library/jest-dom';
+
+import { renderWithProvider } from '#test-utils';
+
+import { StatusLight } from '..';
+
+describe('status-light/StatusLight', () => {
+  it('renders', () => {
+    const { queryByText } = renderWithProvider(<StatusLight>Test</StatusLight>);
+
+    expect(queryByText('Test')).toBeTruthy();
+  });
+
+  it('accepts dom props', () => {
+    const { getByTestId } = renderWithProvider(
+      <StatusLight data-testid="foo" id="bar">
+        Test
+      </StatusLight>
+    );
+
+    expect(getByTestId('foo')).toHaveAttribute('id', 'bar');
+  });
+});