Skip to content

Commit

Permalink
Breadcrumbs component (#12115)
Browse files Browse the repository at this point in the history
This PR is a prerequisite for enso-org/cloud-v2#1656
Closes: enso-org/cloud-v2#1714

Please refer to storybook for visual review.

![CleanShot 2025-01-24 at 14 05 17@2x](https://github.com/user-attachments/assets/f12df1d9-3ed0-40c5-9897-951873a1166f)

Features:
1. Collapses intermediate elements into a menu
2. Allows to add start and end addons (E.g. add a dropdown with actions)
3. Displays icons
4. Truncate long text and display a popup
5. Can be disabled.
6. Current page is not clickable.
  • Loading branch information
MrFlashAccount authored Jan 27, 2025
1 parent e26b9c6 commit 08ecd3d
Show file tree
Hide file tree
Showing 33 changed files with 1,955 additions and 668 deletions.
32 changes: 17 additions & 15 deletions .github/workflows/gui-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,10 @@ jobs:
with:
path: |
**/.eslintcache
key: ${{ runner.os }}-gui-${{ github.run_id }}
**/node_modules/.vite
key: ${{ runner.os }}-gui-lint-${{ github.run_id }}
restore-keys: |
${{ runner.os }}-gui
${{ runner.os }}-gui-lint
# Next Tasks are depend on Typecheck, because we build libraries at this stage
- name: ⚙️ Compile
Expand All @@ -68,11 +69,6 @@ jobs:
continue-on-error: true
run: pnpm run ci:lint

- name: 🧪 Unit Tests
id: unit-tests
continue-on-error: true
run: pnpm run ci:unit-test

- name: 📝 Annotate Code Linting Results
if: always()
continue-on-error: true
Expand All @@ -86,14 +82,10 @@ jobs:
fail-on-error: false
fail-on-warning: false

- name: ❌ Fail if any check failed
if: always() && (steps.lint.outcome == 'failure' || steps.compile.outcome == 'failure' || steps.typecheck.outcome == 'failure' || steps.unit-tests.outcome == 'failure')
run: |
echo "Lint outcome: ${{ steps.lint.outcome }}"
echo "Compile outcome: ${{ steps.compile.outcome }}"
echo "Typecheck outcome: ${{ steps.typecheck.outcome }}"
echo "Unit tests outcome: ${{ steps.unit-tests.outcome }}"
exit 1
- name: 🧪 Unit Tests
id: unit-tests
continue-on-error: true
run: pnpm run ci:unit-test

- name: 💾 Save cache
uses: actions/cache/save@v4
Expand All @@ -103,6 +95,16 @@ jobs:
key: ${{ steps.cache.outputs.cache-primary-key }}
path: |
**/.eslintcache
**/node_modules/.vite
- name: ❌ Fail if any check failed
if: always() && (steps.lint.outcome == 'failure' || steps.compile.outcome == 'failure' || steps.typecheck.outcome == 'failure' || steps.unit-tests.outcome == 'failure')
run: |
echo "Lint outcome: ${{ steps.lint.outcome }}"
echo "Compile outcome: ${{ steps.compile.outcome }}"
echo "Typecheck outcome: ${{ steps.typecheck.outcome }}"
echo "Unit tests outcome: ${{ steps.unit-tests.outcome }}"
exit 1
playwright:
name: 🎭 Playwright Tests
Expand Down
2 changes: 1 addition & 1 deletion app/common/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,6 @@
"@types/node": "^20.11.21",
"lib0": "^0.2.99",
"react": "^18.3.1",
"vitest": "3.0.0-beta.3"
"vitest": "3.0.3"
}
}
1 change: 1 addition & 0 deletions app/common/src/text/english.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"submit": "Submit",
"retry": "Retry",
"hide": "Hide",
"more": "More",

"arbitraryFetchError": "An error occurred while fetching data",
"arbitraryFetchImageError": "An error occurred while fetching an image",
Expand Down
26 changes: 13 additions & 13 deletions app/gui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@
"test-dev:unit": "vitest",
"test-dev:integration": "cross-env NODE_ENV=production playwright test --ui",
"test-dev-dashboard:integration": "cross-env NODE_ENV=production playwright test ./integration-test/dashboard/ --ui",
"storybook:react": "cross-env FRAMEWORK=react storybook dev",
"storybook:vue": "cross-env FRAMEWORK=vue storybook dev",
"storybook:react": "cross-env FRAMEWORK=react storybook dev --port 6006 --no-open",
"storybook:vue": "cross-env FRAMEWORK=vue storybook dev --port 7007 --no-open",
"build-storybook:react": "cross-env FRAMEWORK=react storybook build",
"build-storybook:vue": "cross-env FRAMEWORK=vue storybook build",
"chromatic:react": "cross-env FRAMEWORK=react chromatic deploy",
Expand Down Expand Up @@ -147,15 +147,15 @@
"@open-rpc/server-js": "^1.9.5",
"@playwright/test": "^1.49.1",
"@react-types/shared": "3.27.0",
"@storybook/addon-essentials": "^8.4.7",
"@storybook/addon-interactions": "^8.4.7",
"@storybook/addon-onboarding": "^8.4.7",
"@storybook/blocks": "^8.4.7",
"@storybook/react": "^8.4.7",
"@storybook/react-vite": "^8.4.7",
"@storybook/test": "^8.4.7",
"@storybook/vue3": "^8.4.7",
"@storybook/vue3-vite": "^8.4.7",
"@storybook/addon-essentials": "8.5.0",
"@storybook/addon-interactions": "8.5.0",
"@storybook/addon-onboarding": "8.5.0",
"@storybook/blocks": "8.5.0",
"@storybook/react": "8.5.0",
"@storybook/react-vite": "8.5.0",
"@storybook/test": "8.5.0",
"@storybook/vue3": "8.5.0",
"@storybook/vue3-vite": "8.5.0",
"@tanstack/react-query-devtools": "5.59.20",
"@testing-library/jest-dom": "6.6.3",
"@testing-library/react": "16.0.1",
Expand Down Expand Up @@ -200,7 +200,7 @@
"resize-observer-polyfill": "1.5.1",
"shuffle-seed": "^1.1.6",
"sql-formatter": "^13.1.0",
"storybook": "^8.4.7",
"storybook": "8.5.0",
"tailwindcss": "^3.4.17",
"tailwindcss-animate": "1.0.7",
"tailwindcss-react-aria-components": "1.2.0",
Expand All @@ -209,7 +209,7 @@
"vite": "^6.0.9",
"vite-plugin-vue-devtools": "7.6.8",
"vite-plugin-wasm": "^3.4.1",
"vitest": "3.0.0-beta.3",
"vitest": "3.0.3",
"vue-react-wrapper": "^0.3.1",
"vue-tsc": "^2.2.0",
"yaml": "^2.7.0",
Expand Down
3 changes: 3 additions & 0 deletions app/gui/src/dashboard/assets/expand_arrow.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const STYLES = tv({
width: { auto: 'w-auto', full: 'w-full', min: 'w-min', max: 'w-max' },
gap: {
custom: '',
none: 'gap-0',
joined: 'gap-0',
large: 'gap-3.5',
medium: 'gap-2',
Expand Down
28 changes: 6 additions & 22 deletions app/gui/src/dashboard/components/AriaComponents/Button/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import type * as aria from '#/components/aria'
import type { ExtractFunction } from '#/utilities/tailwindVariants'
import type { ReactElement, ReactNode } from 'react'
import type { Addon, IconProp, TestIdProps } from '../types'
import type { BUTTON_STYLES, ButtonVariants } from './variants'

/**
Expand Down Expand Up @@ -61,19 +62,15 @@ interface PropsWithoutHref {

/** Base props for a button. */
export interface BaseButtonProps<Render>
extends Omit<ButtonVariants, 'iconOnly' | 'isJoined' | 'position'> {
extends Omit<ButtonVariants, 'iconOnly' | 'isJoined' | 'position'>,
TestIdProps {
/** If `true`, the loader will not be shown. */
readonly hideLoader?: boolean
/** Falls back to `aria-label`. Pass `false` to explicitly disable the tooltip. */
readonly tooltip?: ReactElement | string | false | null
readonly tooltipPlacement?: aria.Placement
/** The icon to display in the button */
readonly icon?:
| ReactElement
| string
| ((render: Render) => ReactElement | string | null)
| null
| undefined
readonly icon?: IconProp<Render>
/** When `true`, icon will be shown only when hovered. */
readonly showIconOnHover?: boolean
/**
Expand All @@ -82,7 +79,6 @@ export interface BaseButtonProps<Render>
*/
readonly onPress?: ((event: aria.PressEvent) => Promise<void> | void) | null | undefined
readonly contentClassName?: string
readonly testId?: string
readonly isDisabled?: boolean
readonly formnovalidate?: boolean
/**
Expand All @@ -94,20 +90,8 @@ export interface BaseButtonProps<Render>

readonly children?: ReactNode | ((render: Render) => ReactNode)

readonly addonStart?:
| ReactElement
| string
| false
| ((render: Render) => ReactElement | string | null)
| null
| undefined
readonly addonEnd?:
| ReactElement
| string
| false
| ((render: Render) => ReactElement | string | null)
| null
| undefined
readonly addonStart?: Addon<Render>
readonly addonEnd?: Addon<Render>
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ export const BUTTON_STYLES = tv({
},
iconOnly: {
// Specified in the compoundVariants
true: '',
true: 'aspect-square',
},
rounded: {
full: 'rounded-full',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import * as twv from '#/utilities/tailwindVariants'

import { useEventCallback } from '#/hooks/eventCallbackHooks'
import { ResetButtonGroupContext } from '../Button'
import { Close } from './Close'
import * as dialogProvider from './DialogProvider'
import * as dialogStackProvider from './DialogStackProvider'
import { DialogTrigger } from './DialogTrigger'
Expand Down Expand Up @@ -86,7 +87,6 @@ export function Popover(props: PopoverProps) {
size,
rounded,
variant,
placement = 'bottom start',
isDismissable = true,
...ariaPopoverProps
} = props
Expand All @@ -110,7 +110,6 @@ export function Popover(props: PopoverProps) {
})
}
UNSTABLE_portalContainer={root}
placement={placement}
style={popoverStyle}
shouldCloseOnInteractOutside={() => false}
{...ariaPopoverProps}
Expand Down Expand Up @@ -209,3 +208,4 @@ function PopoverContent(props: PopoverContentProps) {
}

Popover.Trigger = DialogTrigger
Popover.Close = Close
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,19 @@ import EyeClosed from '#/assets/eye_crossed.svg'
import Folder from '#/assets/folder.svg'
import type { Meta, StoryObj } from '@storybook/react'

import { useText } from '#/providers/TextProvider'
import { expect, userEvent, within } from '@storybook/test'
import type { MenuProps } from '.'
import { Menu } from '.'
import { passwordSchema } from '../../../pages/authentication/schemas'
import { Button } from '../Button'
import { Popover } from '../Dialog'
import { Form } from '../Form'
import { Input } from '../Inputs'

const meta = {
title: 'Components/Menu',
component: Menu,
parameters: {
layout: 'centered',
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement)
const button = canvas.getByRole('button', { name: 'Open Menu' })
Expand Down Expand Up @@ -189,7 +191,7 @@ function MenuContentWithDescription() {
<Menu.Item icon={Folder} description="This is a description" shortcut="⌘O">
Open Submenu
</Menu.Item>
<Menu selectionMode="multiple" placement="right">
<Menu selectionMode="multiple">
<Menu.Item description="This is a description" icon={Eye}>
Submenu item
</Menu.Item>
Expand Down Expand Up @@ -268,3 +270,68 @@ export const DynamicContent: Story = {
await expect(canvas.getByRole('menu')).toBeInTheDocument()
},
}

export const WithPopover: Story = {
render: () => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const { getText } = useText()
return (
<Menu.Trigger>
<Button>Open Menu</Button>

<Menu>
<Menu.Item>New File</Menu.Item>

<Menu.Separator />

<Menu.Item>Save</Menu.Item>
<Menu.Item>Cut</Menu.Item>
<Menu.Item>Copy</Menu.Item>
<Menu.Item>Paste</Menu.Item>
<Menu.Item>Delete</Menu.Item>
<Menu.Item>Rename</Menu.Item>
<Menu.Item>Move</Menu.Item>

<Menu.SubmenuTrigger>
<Menu.Item>Edit Secret</Menu.Item>

<Popover isDismissable={false}>
<Form
method="dialog"
schema={(z) => z.object({ name: z.string(), password: passwordSchema(getText) })}
onSubmit={() => new Promise((resolve) => setTimeout(resolve, 1000))}
>
<Input name="name" label="Name" />
<Input name="password" type="password" label="Password" testId="password" />

<Button.Group>
<Form.Submit>Save</Form.Submit>
<Popover.Close>Cancel</Popover.Close>
</Button.Group>
<Form.FormError />
</Form>
</Popover>
</Menu.SubmenuTrigger>
</Menu>
</Menu.Trigger>
)
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement)

const button = canvas.getByRole('button', { name: 'Open Menu' })
await userEvent.click(button)

await userEvent.hover(canvas.getByRole('menuitem', { name: 'Edit Secret' }))

const nameInput = await canvas.findByRole('textbox', { name: 'Name' })
await userEvent.type(nameInput, 'John')

// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const passwordInput = canvas.getByTestId('password').querySelector('input')!
await userEvent.type(passwordInput, 'abc123sadflmsdkf')

const saveButton = await canvas.findByRole('button', { name: 'Save' })
await userEvent.click(saveButton)
},
}
12 changes: 2 additions & 10 deletions app/gui/src/dashboard/components/AriaComponents/Menu/Menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { AnimatedBackground } from '../../AnimatedBackground'
import { Popover } from '../Dialog'
import { Separator, SEPARATOR_STYLES, type SeparatorProps } from '../Separator'
import { Text } from '../Text'
import type { Placement, TestIdProps } from '../types'
import type { TestIdProps } from '../types'
import { MenuItem } from './MenuItem'
import { MenuTrigger } from './MenuTrigger'

Expand Down Expand Up @@ -45,7 +45,6 @@ export interface MenuProps<T extends object>
TestIdProps {
readonly variant?: 'dark' | 'light'
readonly className?: string
readonly placement?: Placement
}

/** Props for {@link MenuSection} */
Expand Down Expand Up @@ -91,7 +90,6 @@ export const Menu = createHideableComponent(function Menu<T extends object>(prop
variant,
className,
children,
placement = 'bottom start',
variants = MENU_STYLES,
testId = 'menu',
...menuProps
Expand All @@ -100,13 +98,7 @@ export const Menu = createHideableComponent(function Menu<T extends object>(prop
const styles = variants()

return (
<Popover
variant={variant}
placement={placement}
className={styles.popover()}
size="xxsmall"
rounded="xxxlarge"
>
<Popover variant={variant} className={styles.popover()} size="xxsmall" rounded="xxxlarge">
{() => (
<AnimatedBackground>
<aria.Menu<T> data-testid={testId} className={styles.base({ className })} {...menuProps}>
Expand Down
Loading

0 comments on commit 08ecd3d

Please sign in to comment.