Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/components/MswBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export function MswBanner() {
return (
<>
{/* The [&+*]:pt-10 style is to ensure the page container isn't pushed out of screen as it uses 100vh for layout */}
<label className="absolute flex h-10 w-full items-center justify-center text-sans-md text-info-secondary bg-info-secondary [&+*]:pt-10">
<label className="absolute z-topBar flex h-10 w-full items-center justify-center text-sans-md text-info-secondary bg-info-secondary [&+*]:pt-10">
<Info16Icon className="mr-2" /> This is a technical preview.
<button
className="ml-2 flex items-center gap-0.5 text-sans-md hover:text-info"
Expand Down
2 changes: 1 addition & 1 deletion app/components/ToastStack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export function ToastStack() {
})

return (
<div className="pointer-events-auto fixed bottom-4 left-4 z-50 flex flex-col items-end space-y-2">
<div className="pointer-events-auto fixed bottom-4 left-4 z-toast flex flex-col items-end space-y-2">
{transition((style, item) => (
<animated.div
style={{
Expand Down
2 changes: 1 addition & 1 deletion app/components/TopBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export function TopBar({ children }: { children: React.ReactNode }) {
</div>
{/* Height is governed by PageContainer grid */}
{/* shrink-0 is needed to prevent getting squished by body content */}
<div className="border-b bg-default border-secondary">
<div className="z-topBar border-b bg-default border-secondary">
<div className="mx-3 flex h-[60px] shrink-0 items-center justify-between">
<div className="flex items-center">{otherPickers}</div>
<div>
Expand Down
3 changes: 3 additions & 0 deletions app/components/form/fields/DateTimeRangePicker.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
*/
import { getLocalTimeZone, now as getNow } from '@internationalized/date'
import { fireEvent, render, screen } from '@testing-library/react'
import ResizeObserverPolyfill from 'resize-observer-polyfill'
import { beforeAll, describe, expect, it, vi } from 'vitest'

import { clickByRole } from 'app/test/unit'
Expand Down Expand Up @@ -34,6 +35,8 @@ function renderLastDay() {
}

beforeAll(() => {
global.ResizeObserver = ResizeObserverPolyfill

vi.useFakeTimers()
vi.setSystemTime(now.toDate())

Expand Down
20 changes: 20 additions & 0 deletions app/test/e2e/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,3 +120,23 @@ export async function getDevUserPage(browser: Browser): Promise<Page> {
])
return await browserContext.newPage()
}

/**
* Assert that the item is visible and in the viewport but obscured by something
* else, as indicated by it not being clickable. In order to avoid false
* positives where something is not clickable due to it being not attached or
* something, we assert visible and in viewport first.
*/
export async function expectObscured(locator: Locator) {
// counterintuitively, expect visible does not mean actually visible, it just
// means attached and not having display: none
await expect(locator).toBeVisible()
await expect(locator).toBeInViewport()

// Attempt click with `trial: true`, which means only the actionability checks
// run but the click does not actually happen. Short timeout means this will
// fail fast if not clickable.
await expect(
async () => await locator.click({ trial: true, timeout: 50 })
).rejects.toThrow(/locator.click: Timeout 50ms exceeded/)
}
73 changes: 73 additions & 0 deletions app/test/e2e/z-index.e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
*
* Copyright Oxide Computer Company
*/
import { expect, test } from '@playwright/test'

import { expectObscured } from './utils'

test('Dropdown content can scroll off page and doesn’t hide TopBar', async ({ page }) => {
// load the page
await page.goto('/utilization')
await expect(page.getByText('Capacity & Utilization')).toBeVisible()

const button = page.getByRole('button', { name: 'All projects' })

// click on the 'All projects' dropdown
await button.click()
const options = ['All projects', 'mock-project', 'other-project']
for (const name of options) {
const option = page.getByRole('option', { name })
await expect(option).toBeVisible()
await expect(option).toBeInViewport()
}

// scroll the page down by 275px
await page.mouse.wheel(0, 275)

// if we don't do this, the test doesn't wait long enough for the following
// assertions to become true
await expect(button).not.toBeInViewport()

// now the top the listbox option is obscured by the topbar
await expectObscured(page.getByRole('option', { name: 'All projects' }))

// but we can still click the bottom one
await page.getByRole('option', { name: 'other-project' }).click()
await expect(page.getByRole('button', { name: 'other-project' })).toBeVisible()
})

test('Dropdown content in SidebarModal shows on screen', async ({ page }) => {
// go to an instance’s Network Interfaces page
await page.goto('/projects/mock-project/instances/db1/network-interfaces')

// stop the instance
await page.getByRole('button', { name: 'Instance actions' }).click()
await page.getByRole('menuitem', { name: 'Stop' }).click()

// open the add network interface side modal
await page.getByRole('button', { name: 'Add network interface' }).click()

// fill out the form
await page.getByLabel('Name').fill('alt-nic')

// select the VPC and subnet via the dropdowns. The fact that the options are
// clickable means they are not obscured due to having a too-low z-index
await page.getByRole('button', { name: 'VPC' }).click()
await page.getByRole('option', { name: 'mock-vpc' }).click()
await page.getByRole('button', { name: 'Subnet' }).click()
await page.getByRole('option', { name: 'mock-subnet' }).click()

const sidebar = page.getByRole('dialog', { name: 'Add network interface' })

// verify that the SideModal header is positioned above the TopBar
await expectObscured(page.getByRole('button', { name: 'User menu' }))

// test that the form can be submitted and a new network interface is created
await sidebar.getByRole('button', { name: 'Add network interface' }).click()
await expect(sidebar).toBeHidden()
await expect(page.getByText('alt-nic')).toBeVisible()
})
2 changes: 1 addition & 1 deletion libs/ui/lib/date-picker/Popover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export function Popover(props: PopoverProps) {
<div
{...popoverProps}
ref={ref}
className="rounded-md absolute top-full z-10 mt-2 rounded-lg border bg-raise border-secondary elevation-2"
className="rounded-md absolute top-full z-popover mt-2 rounded-lg border bg-raise border-secondary elevation-2"
>
<DismissButton onDismiss={state.close} />
{children}
Expand Down
25 changes: 21 additions & 4 deletions libs/ui/lib/listbox/Listbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,22 @@
*
* Copyright Oxide Computer Company
*/
import { FloatingPortal, flip, offset, size, useFloating } from '@floating-ui/react'
import {
FloatingPortal,
autoUpdate,
flip,
offset,
size,
useFloating,
} from '@floating-ui/react'
import { Listbox as Select } from '@headlessui/react'
import cn from 'classnames'
import type { ReactNode } from 'react'

import { FieldLabel, SpinnerLoader, TextInputHint } from '@oxide/ui'
import { SelectArrows6Icon } from '@oxide/ui'
import { FieldLabel, SelectArrows6Icon, SpinnerLoader, TextInputHint } from '@oxide/ui'

import { useIsInModal } from '../modal/Modal'
import { useIsInSideModal } from '../side-modal/SideModal'

export type ListboxItem<Value extends string = string> = {
value: Value
Expand Down Expand Up @@ -68,11 +77,19 @@ export const Listbox = <Value extends string = string>({
},
}),
],
whileElementsMounted: autoUpdate,
})

const selectedItem = selected && items.find((i) => i.value === selected)
const noItems = !isLoading && items.length === 0
const isDisabled = disabled || noItems
const isInModal = useIsInModal()
const isInSideModal = useIsInSideModal()
const zIndex = isInModal
? 'z-modalDropdown'
: isInSideModal
? 'z-sideModalDropdown'
: 'z-contentDropdown'

return (
<div className={cn('relative', className)}>
Expand Down Expand Up @@ -134,7 +151,7 @@ export const Listbox = <Value extends string = string>({
<Select.Options
ref={refs.setFloating}
style={floatingStyles}
className="ox-menu pointer-events-auto z-50 overflow-y-auto !outline-none"
className={`ox-menu pointer-events-auto ${zIndex} overflow-y-auto !outline-none`}
>
{items.map((item) => (
<Select.Option
Expand Down
6 changes: 2 additions & 4 deletions libs/ui/lib/modal/Modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,7 @@ import { Close12Icon } from '../icons'

const ModalContext = createContext(false)

export const useIsInModal = () => {
return useContext(ModalContext)
}
export const useIsInModal = () => useContext(ModalContext)

export type ModalProps = {
title?: string
Expand Down Expand Up @@ -63,7 +61,7 @@ export function Modal({ children, onDismiss, title, isOpen }: ModalProps) {
aria-hidden
/>
<AnimatedDialogContent
className="DialogContent ox-modal pointer-events-auto fixed left-1/2 top-1/2 z-40 m-0 flex max-h-[min(800px,80vh)] w-auto min-w-[28rem] max-w-[32rem] flex-col justify-between rounded-lg border p-0 bg-raise border-secondary elevation-2"
className="DialogContent ox-modal pointer-events-auto fixed left-1/2 top-1/2 z-modal m-0 flex max-h-[min(800px,80vh)] w-auto min-w-[28rem] max-w-[32rem] flex-col justify-between rounded-lg border p-0 bg-raise border-secondary elevation-2"
aria-labelledby={titleId}
style={{
transform: y.to((value) => `translate3d(-50%, ${-50 + value}%, 0px)`),
Expand Down
124 changes: 66 additions & 58 deletions libs/ui/lib/side-modal/SideModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import * as Dialog from '@radix-ui/react-dialog'
import { animated, useTransition } from '@react-spring/web'
import cn from 'classnames'
import React, { type ReactNode, useRef } from 'react'
import React, { type ReactNode, createContext, useContext, useRef } from 'react'

import { Message } from '@oxide/ui'
import { classed } from '@oxide/util'
Expand All @@ -18,6 +18,10 @@ import { useIsOverflow } from 'app/hooks'
import { Close12Icon, Error12Icon } from '../icons'
import './side-modal.css'

const SideModalContext = createContext(false)

export const useIsInSideModal = () => useContext(SideModalContext)

export type SideModalProps = {
title?: string
subtitle?: ReactNode
Expand Down Expand Up @@ -53,65 +57,69 @@ export function SideModal({
config: isOpen && animate ? config : { duration: 0 },
})

return transitions(
({ x }, item) =>
item && (
<Dialog.Root
open
onOpenChange={(open) => {
if (!open) onDismiss()
}}
// https://github.com/radix-ui/primitives/issues/1159#issuecomment-1559813266
modal={false}
>
<Dialog.Portal>
<div
className="DialogOverlay pointer-events-auto"
onClick={onDismiss}
aria-hidden
/>
<AnimatedDialogContent
className="DialogContent ox-side-modal pointer-events-auto fixed bottom-0 right-0 top-0 m-0 flex w-[32rem] flex-col justify-between border-l p-0 bg-raise border-secondary elevation-2"
aria-labelledby={titleId}
style={{
transform: x.to((value) => `translate3d(${value}%, 0px, 0px)`),
return (
<SideModalContext.Provider value>
{transitions(
({ x }, item) =>
item && (
<Dialog.Root
open
onOpenChange={(open) => {
if (!open) onDismiss()
}}
// https://github.com/radix-ui/primitives/issues/1159#issuecomment-1559813266
modal={false}
>
{title && (
<Dialog.Title asChild>
<>
<SideModal.Title id={titleId} title={title} subtitle={subtitle} />

{errors && errors.length > 0 && (
<div className="mb-6">
<Message
variant="error"
content={
errors.length === 1 ? (
errors[0]
) : (
<>
<div>{errors.length} issues:</div>
<ul className="ml-4 list-disc">
{errors.map((error, idx) => (
<li key={idx}>{error}</li>
))}
</ul>
</>
)
}
title={errors.length > 1 ? 'Errors' : 'Error'}
/>
</div>
)}
</>
</Dialog.Title>
)}
{children}
</AnimatedDialogContent>
</Dialog.Portal>
</Dialog.Root>
)
<Dialog.Portal>
<div
className="DialogOverlay pointer-events-auto"
onClick={onDismiss}
aria-hidden
/>
<AnimatedDialogContent
className="DialogContent ox-side-modal pointer-events-auto fixed bottom-0 right-0 top-0 z-sideModal m-0 flex w-[32rem] flex-col justify-between border-l p-0 bg-raise border-secondary elevation-2"
aria-labelledby={titleId}
style={{
transform: x.to((value) => `translate3d(${value}%, 0px, 0px)`),
}}
>
{title && (
<Dialog.Title asChild>
<>
<SideModal.Title id={titleId} title={title} subtitle={subtitle} />

{errors && errors.length > 0 && (
<div className="mb-6">
<Message
variant="error"
content={
errors.length === 1 ? (
errors[0]
) : (
<>
<div>{errors.length} issues:</div>
<ul className="ml-4 list-disc">
{errors.map((error, idx) => (
<li key={idx}>{error}</li>
))}
</ul>
</>
)
}
title={errors.length > 1 ? 'Errors' : 'Error'}
/>
</div>
)}
</>
</Dialog.Title>
)}
{children}
</AnimatedDialogContent>
</Dialog.Portal>
</Dialog.Root>
)
)}
</SideModalContext.Provider>
)
}

Expand Down
2 changes: 1 addition & 1 deletion libs/ui/styles/components/menu-list.css
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
*/

.ox-menu {
@apply z-50 max-h-[17.5rem] overflow-y-auto rounded border bg-raise border-secondary elevation-2;
@apply max-h-[17.5rem] overflow-y-auto rounded border bg-raise border-secondary elevation-2;
}

.ox-menu-item {
Expand Down
Loading