From a0e82a1443418462743727abc63ceeda45653364 Mon Sep 17 00:00:00 2001 From: Jonathan Brennan Date: Sat, 19 Sep 2026 16:49:54 -0500 Subject: [PATCH 1/8] foundry previews for ui/accordion and v2/accordion call sites --- packages/web/app/foundry.config.ts | 2 +- .../accordion.call-sites.preview.tsx | 633 ++++++++++++++++++ 2 files changed, 634 insertions(+), 1 deletion(-) create mode 100644 packages/web/app/src/components/base/accordion/accordion.call-sites.preview.tsx diff --git a/packages/web/app/foundry.config.ts b/packages/web/app/foundry.config.ts index b78920a53c..8977cc4ecf 100644 --- a/packages/web/app/foundry.config.ts +++ b/packages/web/app/foundry.config.ts @@ -47,7 +47,7 @@ export default defineConfig({ { label: 'Primitives', children: [ - { label: 'Accordion' }, + { label: 'Accordion', children: [{ label: 'Component Examples' }] }, { label: 'Avatar', children: [{ label: 'Component Examples' }] }, { label: 'Badge', children: [{ label: 'Component Examples' }] }, { label: 'StatusDot' }, diff --git a/packages/web/app/src/components/base/accordion/accordion.call-sites.preview.tsx b/packages/web/app/src/components/base/accordion/accordion.call-sites.preview.tsx new file mode 100644 index 0000000000..845826678d --- /dev/null +++ b/packages/web/app/src/components/base/accordion/accordion.call-sites.preview.tsx @@ -0,0 +1,633 @@ +import { useState } from 'react'; +import { FolderIcon, FolderOpenIcon } from 'lucide-react'; +import { createPreview, type NavPath } from 'react-foundry'; +import { Badge } from '@/components/base/badge/badge'; +import { Menu } from '@/components/base/floating/menu/menu'; +import { Select } from '@/components/base/floating/select/select'; +import { CallSite, InventoryList } from '@/components/inventory/shared'; +import { PermissionTable } from '@/components/organization/permission-table'; +import { + Accordion, + AccordionContent, + AccordionHeader, + AccordionItem, + AccordionTrigger, + AccordionTriggerPrimitive, +} from '@/components/ui/accordion'; +import { PulseIcon } from '@/components/ui/icon'; +import { Accordion as V2Accordion } from '@/components/v2/accordion'; +import { + CheckIcon, + DotsHorizontalIcon, + ExclamationTriangleIcon, + PlusIcon, +} from '@radix-ui/react-icons'; + +export const nav: NavPath = 'Base/Primitives/Accordion/Component Examples'; + +/** + * Every accordion in the app as it ships today, on `ui/accordion` (Radix, shadcn parts) and + * `v2/accordion` (Radix, compound `Accordion.Item/Header/Content`). Nine mounts in eight files. + * The pages around them run queries and mutations, so the content inside each item is stood in + * where it would need one: the schema highlighter is a `
`, the change details a paragraph,
+ * the approval badge a Badge.
+ *
+ * Read with `base/accordion`, which exists (Base UI) and is on one call site, the alert rule
+ * form's Advanced settings. What these sites use that it does not offer is the round's API work.
+ */
+
+const ENTRIES = [
+  {
+    source: 'components/organization/members/permission-selector.tsx:98',
+    origin: 'ui',
+    what: 'Permission groups on the role form: multiple open, controlled, every panel kept mounted, "N selected" at the trigger end',
+    coveredBy: 'Permission selector',
+  },
+  {
+    source: 'components/organization/members/selected-permission-overview.tsx:151',
+    origin: 'ui',
+    what: 'A role\'s granted permissions: one item, open by default when anything is granted, "N allowed" at the trigger end',
+    coveredBy: 'Permission overview',
+  },
+  {
+    source: 'components/organization/settings/access-tokens/permission-detail-view.tsx:45',
+    origin: 'ui',
+    what: "An access token's permissions per level, the same shape plus the resources it was granted on",
+    coveredBy: 'Permission overview',
+  },
+  {
+    source: 'components/target/history/errors-and-changes.tsx:233',
+    origin: 'ui',
+    what: 'One accordion per schema change on the check, version and proposal pages: a rich trigger, details below',
+    coveredBy: 'Schema changes',
+  },
+  {
+    source: 'components/target/proposals/change-detail.tsx:22',
+    origin: 'v2',
+    what: 'A proposal change: v2 root around ui parts, message and an icon in the trigger',
+    coveredBy: 'Proposal change',
+  },
+  {
+    source: 'components/target/settings/registry-access-token.tsx:231',
+    origin: 'v2',
+    what: "The registry token form's permission section, open by default",
+    coveredBy: 'Registry token',
+  },
+  {
+    source: 'pages/target.tsx:74',
+    origin: 'v2',
+    what: 'The services of a federation target, each a boxed item with a two-line header and the SDL inside',
+    coveredBy: 'Target services',
+  },
+  {
+    source: 'pages/target.tsx:94',
+    origin: 'v2',
+    what: 'A single service: the same item, always open, trigger disabled',
+    coveredBy: 'Target services',
+  },
+  {
+    source: 'lib/hooks/laboratory/use-operation-collections-plugin.tsx:302',
+    origin: 'ui',
+    what: 'Laboratory collections: multiple open, controlled, a raw trigger with folder icons and a menu beside it, e2e hooks',
+    coveredBy: 'Laboratory collections',
+  },
+] as const;
+
+export const Inventory = createPreview({
+  label: 'Inventory',
+  render: () => (
+    
+          Nine mounts. Six on ui/accordion, which is Radix with a chevron trigger and a bordered
+          item; three on v2/accordion, which is Radix with a boxed header and its own chevron, and
+          always collapsible. One site mixes the two. Base already has an Accordion on Base UI with
+          numeric values and a text-only trigger, used once.
+        
+      }
+      entries={ENTRIES}
+    />
+  ),
+});
+
+// ---------------------------------------------------------------------------
+// components/organization/members/permission-selector.tsx:98
+// ---------------------------------------------------------------------------
+
+const PERMISSION_GROUPS = [
+  {
+    title: 'Organization',
+    permissions: [
+      {
+        id: 'organization:describe',
+        title: 'Describe organization',
+        description: 'Fetch information about the organization.',
+      },
+      {
+        id: 'member:modify',
+        title: 'Modify members',
+        description: 'Invite, remove and assign roles.',
+      },
+    ],
+  },
+  {
+    title: 'Project',
+    permissions: [
+      {
+        id: 'project:describe',
+        title: 'Describe project',
+        description: 'Fetch information about projects.',
+      },
+      { id: 'project:delete', title: 'Delete project', description: 'Delete projects.' },
+    ],
+  },
+  {
+    title: 'Schema Registry',
+    permissions: [
+      { id: 'schemaCheck:create', title: 'Check schema', description: 'Run schema checks.' },
+      {
+        id: 'schemaVersion:publish',
+        title: 'Publish schema',
+        description: 'Publish schema versions.',
+      },
+      {
+        id: 'schemaCheck:approve',
+        title: 'Approve failed check',
+        description: 'Approve failed schema checks.',
+      },
+    ],
+  },
+];
+
+function PermissionSelector() {
+  const [openAccordions, setOpenAccordions] = useState([] as Array);
+  const [selected, setSelected] = useState(new Set(['organization:describe', 'project:describe']));
+  return (
+     setOpenAccordions(values)}
+    >
+      {PERMISSION_GROUPS.map(group => {
+        const count = group.permissions.filter(p => selected.has(p.id)).length;
+        return (
+          
+            
+              {group.title}{' '}
+              
+                {count > 0 && {count} selected}
+              
+            
+            
+              {group.permissions.map(permission => (
+                
+
+
{permission.title}
+
{permission.description}
+
+ +
+ + + + + + ), +}); + +// --------------------------------------------------------------------------- +// pages/target.tsx:74 and :94 +// --------------------------------------------------------------------------- + +const SERVICES = [ + { + id: 's1', + service: 'users', + url: 'https://users.storefront.local/graphql', + source: + 'type Query {\n me: User\n user(id: ID!): User\n}\n\ntype User @key(fields: "id") {\n id: ID!\n fullName: String!\n}', + }, + { + id: 's2', + service: 'products', + url: 'https://products.storefront.local/graphql', + source: + 'type Query {\n products(first: Int = 20): [Product!]!\n}\n\ntype Product @key(fields: "id") {\n id: ID!\n name: String!\n}', + }, +]; + +function SchemaBlock({ schema }: { schema: (typeof SERVICES)[number] }) { + return ( + + +
+
{schema.service}
+
{schema.url}
+
+
+ +
+ {/* GraphQLHighlight is a Monaco editor; a code block stands in for it. */} +
{schema.source}
+
+
+
+ ); +} + +export const TargetServicesPreview = createPreview({ + label: 'Target services', + render: () => ( +
+ +
+ + {SERVICES.map(schema => ( + + ))} + +
+
+ +
+ + + +
+
+
+ ), +}); + +// --------------------------------------------------------------------------- +// lib/hooks/laboratory/use-operation-collections-plugin.tsx:302 +// --------------------------------------------------------------------------- + +const COLLECTIONS = [ + { + id: 'c1', + name: 'Onboarding', + operations: ['GetViewer', 'ListProjects', 'CreateTarget'], + }, + { id: 'c2', name: 'Regression', operations: ['PublishSchema'] }, + { id: 'c3', name: 'Scratch', operations: [] }, +]; + +function Collections() { + const [value, setValue] = useState(['c1']); + return ( + + {COLLECTIONS.map(collection => ( + + + + + + {collection.name} + + + + + } + sections={[ + [{ label: 'Add operation', trailingIcon: PlusIcon, onClick: () => {} }], + [ + { label: 'Edit', onClick: () => {} }, + { label: 'Delete', variant: 'destructiveAction', onClick: () => {} }, + ], + ]} + /> + + + {collection.operations.length ? ( + collection.operations.map(name => ( + + )) + ) : ( + + )} + + + ))} + + ); +} + +export const LaboratoryCollectionsPreview = createPreview({ + label: 'Laboratory collections', + render: () => ( + +
+ +
+
+ ), +}); From f139a869fe21fc9c9a8c3ee6549e8eaf152a7a11 Mon Sep 17 00:00:00 2001 From: Jonathan Brennan Date: Sat, 19 Sep 2026 16:50:34 -0500 Subject: [PATCH 2/8] rebuild base Accordion on an items API --- .../base/accordion/accordion.preview.tsx | 319 +++++++++++++++--- .../base/accordion/accordion.spec.tsx | 66 ++++ .../components/base/accordion/accordion.tsx | 209 +++++++++--- .../components/target/alerts/alert-form.tsx | 150 ++++---- 4 files changed, 576 insertions(+), 168 deletions(-) create mode 100644 packages/web/app/src/components/base/accordion/accordion.spec.tsx diff --git a/packages/web/app/src/components/base/accordion/accordion.preview.tsx b/packages/web/app/src/components/base/accordion/accordion.preview.tsx index 4ac13112b8..33633d311f 100644 --- a/packages/web/app/src/components/base/accordion/accordion.preview.tsx +++ b/packages/web/app/src/components/base/accordion/accordion.preview.tsx @@ -1,71 +1,278 @@ -import { createPreview, type NavPath } from 'react-foundry'; -import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from './accordion'; +import { useState, type ReactNode } from 'react'; +import { FolderIcon, FolderOpenIcon, MoreHorizontal, Plus } from 'lucide-react'; +import { controlsFor, createPreview, type NavPath } from 'react-foundry'; +import { Badge } from '../badge/badge'; +import { Menu } from '../floating/menu/menu'; +import { Accordion } from './accordion'; export const nav: NavPath = 'Base/Primitives/Accordion'; +/** + * Items in, an accordion out, on Base UI. One trigger look: medium text, the chevron at the end, + * no underline on hover. Each example below is drawn at the call site it will ship at, so the + * Component Examples beside it, which still render the old components, can be compared like for + * like. + */ + +function Copy({ children }: { children: ReactNode }) { + return

{children}

; +} + +function Labelled(props: { label: string; children: ReactNode }) { + return ( +
+ {props.label} + {props.children} +
+ ); +} + +const CHANGES = [ + { + value: 'breaking', + label: 'Breaking changes', + body: 'Field User.email was removed from type User.', + }, + { + value: 'dangerous', + label: 'Dangerous changes', + body: 'Enum value PENDING was added to Status.', + }, + { value: 'safe', label: 'Safe changes', body: 'Description on type Query has changed.' }, +]; + export const Default = createPreview(() => (
- - - - -

- Field User.email was removed from type User. -

-
-
- - - -

- Enum value PENDING was added to Status. -

-
-
- - - -

Description on type Query has changed.

-
-
-
+ ({ + value: change.value, + label: change.label, + content: {change.body}, + }))} + />
)); +/** `defaultValue` opens items on mount; `multiple` lets more than one stay open. */ export const DefaultOpen = createPreview(() => ( -
- - - - -

Driven by the `defaultValue` prop.

-
-
- - - -

Not listed in `defaultValue`.

-
-
-
+
+ + ({ + value: change.value, + label: change.label, + content: {change.body}, + }))} + /> + + + ({ + value: change.value, + label: change.label, + content: {change.body}, + }))} + /> +
)); -export const AccentVariant = createPreview(() => ( +const PERMISSION_GROUPS = [ + { value: 'Organization', selected: 2, total: 4 }, + { value: 'Project', selected: 0, total: 3 }, + { value: 'Schema Registry', selected: 3, total: 3 }, +]; + +/** + * members/permission-selector.tsx: the open set held by the form, a count at the trigger's far + * end. `trailing` sits before the chevron. + */ +export const Controlled = createPreview(() => { + const [open, setOpen] = useState([]); + return ( +
+ ({ + value: group.value, + label: group.value, + trailing: group.selected ? ( + {group.selected} selected + ) : undefined, + content: {group.total} permissions in this group., + }))} + /> +

Open: {open.join(', ') || 'none'}

+
+ ); +}); + +const SERVICES = [ + { id: 's1', service: 'users', url: 'https://users.storefront.local/graphql' }, + { id: 's2', service: 'products', url: 'https://products.storefront.local/graphql' }, +]; + +/** pages/target.tsx: `boxed`, each service a card with a two-line header and its SDL inside. */ +export const Boxed = createPreview(() => ( +
+ ({ + value: schema.id, + label: ( +
+
{schema.service}
+
{schema.url}
+
+ ), + content: ( +
+            {'type Query {\n  me: User\n}'}
+          
+ ), + }))} + /> +
+)); + +/** + * alerts/alert-form.tsx, Advanced settings: small, the chevron at the start as a disclosure, and + * the accent tone to call it out. + */ +export const SmallAccentDisclosure = createPreview(() => ( +
+ On filter, hold minutes., + }, + ]} + /> +
+)); + +const COLLECTIONS = [ + { id: 'c1', name: 'Onboarding', operations: ['GetViewer', 'ListProjects'] }, + { id: 'c2', name: 'Regression', operations: ['PublishSchema'] }, +]; + +/** + * laboratory collections: no chevron, folder icons in the label that swap on open, and a menu + * beside the trigger through `action`, which stays outside the trigger button. + */ +export const WithAction = createPreview(() => { + const [open, setOpen] = useState(['c1']); + return ( +
+ ({ + value: collection.id, + label: ( + + {open.includes(collection.id) ? ( + + ) : ( + + )} + {collection.name} + + ), + action: ( + + + + } + sections={[ + [{ label: 'Add operation', trailingIcon: Plus, onClick: () => {} }], + [ + { label: 'Edit', onClick: () => {} }, + { label: 'Delete', variant: 'destructiveAction', onClick: () => {} }, + ], + ]} + /> + ), + content: ( +
    + {collection.operations.map(name => ( +
  • {name}
  • + ))} +
+ ), + attrs: { 'data-cy': 'collection-item' }, + }))} + /> +
+ ); +}); + +/** A disabled item keeps its place and its label, and cannot open. */ +export const Disabled = createPreview(() => (
- - - - -

Used where the section needs emphasis.

-
-
- - - -

The standard neutral treatment.

-
-
-
+ None. }, + { + value: 'b', + label: ( + + Breaking changes + + + ), + content: Two, both approved., + disabled: true, + }, + ]} + />
)); + +export const Playground = createPreview({ + controls: controlsFor(Accordion, { + variant: { type: 'radio', options: ['list', 'boxed'], default: 'list' }, + size: { type: 'radio', options: ['default', 'sm'], default: 'default' }, + chevron: { type: 'radio', options: ['end', 'start', 'none'], default: 'end' }, + tone: { type: 'radio', options: ['default', 'accent'], default: 'default' }, + multiple: { type: 'boolean', default: false }, + keepMounted: { type: 'boolean', default: false }, + }), + render: v => ( +
+ ({ + value: change.value, + label: change.label, + trailing: index === 0 ? 2 : undefined, + content: {change.body}, + }))} + /> +
+ ), +}); diff --git a/packages/web/app/src/components/base/accordion/accordion.spec.tsx b/packages/web/app/src/components/base/accordion/accordion.spec.tsx new file mode 100644 index 0000000000..c284801ab2 --- /dev/null +++ b/packages/web/app/src/components/base/accordion/accordion.spec.tsx @@ -0,0 +1,66 @@ +// @vitest-environment jsdom +import { fireEvent, render, screen } from '@testing-library/react'; +import { Accordion } from './accordion'; + +const ITEMS = [ + { value: 'a', label: 'Breaking', content:

Breaking panel

, attrs: { 'data-cy': 'row-a' } }, + { value: 'b', label: 'Safe', content:

Safe panel

}, + { value: 'c', label: 'Locked', content:

Locked panel

, disabled: true }, +]; + +describe('Accordion', () => { + it('opens one item at a time by default, and reports the open set', () => { + const onValueChange = vi.fn(); + render(); + expect(screen.queryByText('Breaking panel')).toBeNull(); + + fireEvent.click(screen.getByRole('button', { name: 'Breaking' })); + expect(screen.getByText('Breaking panel')).toBeTruthy(); + expect(onValueChange).toHaveBeenLastCalledWith(['a']); + + fireEvent.click(screen.getByRole('button', { name: 'Safe' })); + expect(screen.queryByText('Breaking panel')).toBeNull(); + expect(screen.getByText('Safe panel')).toBeTruthy(); + expect(onValueChange).toHaveBeenLastCalledWith(['b']); + }); + + it('keeps several open with multiple, and follows a controlled value', () => { + const { rerender } = render( + {}} />, + ); + expect(screen.getByText('Breaking panel')).toBeTruthy(); + expect(screen.getByText('Safe panel')).toBeTruthy(); + + rerender( {}} />); + expect(screen.queryByText('Breaking panel')).toBeNull(); + }); + + it('puts trailing inside the trigger and action beside it, and lands attrs on the item', () => { + render( + 3 selected, + action: , + }, + ]} + />, + ); + const trigger = screen.getByRole('button', { name: /Breaking/ }); + expect(trigger.textContent).toContain('3 selected'); + const more = screen.getByRole('button', { name: 'More' }); + expect(trigger.contains(more)).toBe(false); + expect(document.querySelector('[data-cy="row-a"]')!.contains(more)).toBe(true); + }); + + it('will not open a disabled item', () => { + render(); + const locked = screen.getByRole('button', { name: 'Locked' }); + expect(locked.hasAttribute('disabled') || locked.getAttribute('aria-disabled') === 'true').toBe( + true, + ); + fireEvent.click(locked); + expect(screen.queryByText('Locked panel')).toBeNull(); + }); +}); diff --git a/packages/web/app/src/components/base/accordion/accordion.tsx b/packages/web/app/src/components/base/accordion/accordion.tsx index 27dfe0d69d..144103957f 100644 --- a/packages/web/app/src/components/base/accordion/accordion.tsx +++ b/packages/web/app/src/components/base/accordion/accordion.tsx @@ -1,51 +1,182 @@ import { type ReactNode } from 'react'; +import { cva } from 'class-variance-authority'; import { ChevronDown } from 'lucide-react'; +import { cn } from '@/lib/utils'; import { Accordion as BaseAccordion } from '@base-ui/react/accordion'; +import { focusRingQuiet } from '../shared-styles'; -export function Accordion({ - children, - defaultValue, -}: { - children: ReactNode; - defaultValue?: number[]; -}) { - return {children}; -} +export type AccordionItem = { + value: string; + /** Text, or a two-line header, badges, icons. */ + label: ReactNode; + /** At the far end of the trigger, before the chevron: a count, a status. */ + trailing?: ReactNode; + /** Beside the trigger, outside it: a menu button. A button cannot nest in a button. */ + action?: ReactNode; + content: ReactNode; + disabled?: boolean; + /** Test hooks and the like, landed on the item. */ + attrs?: Record; +}; -export function AccordionItem({ children, value }: { children: ReactNode; value: number }) { - return {children}; -} +export type AccordionVariant = 'list' | 'boxed'; +export type AccordionSize = 'default' | 'sm'; +export type AccordionChevron = 'end' | 'start' | 'none'; +export type AccordionTone = 'default' | 'accent'; -export function AccordionTrigger({ - label, - variant = 'default', -}: { - label: string; - variant?: 'default' | 'accent'; -}) { - const colorClass = - variant === 'accent' - ? 'text-accent hover:text-accent/80' - : 'text-neutral-12 hover:text-neutral-11'; +type AccordionProps = { + items: readonly AccordionItem[]; + value?: string[]; + defaultValue?: string[]; + onValueChange?: (value: string[]) => void; + /** More than one item open at once. */ + multiple?: boolean; + /** `list` draws a hairline under each item; `boxed` gives each item a card border, spaced apart. */ + variant?: AccordionVariant; + size?: AccordionSize; + /** Where the chevron sits. `start` for a disclosure under a form; `none` when the label carries its own icon. */ + chevron?: AccordionChevron; + /** `accent` calls the trigger out: a disclosure that invites, like Advanced settings, rather than a section. */ + tone?: AccordionTone; + /** Closed panels stay in the DOM. */ + keepMounted?: boolean; + attrs?: Record; +}; - const chevronColor = variant === 'accent' ? 'text-accent' : 'text-neutral-8'; +const rootVariants = cva('', { + variants: { + variant: { + list: '', + boxed: 'flex flex-col gap-4', + }, + }, +}); - return ( - svg]:rotate-0`} - > - - {label} - - ); -} +const itemVariants = cva('', { + variants: { + variant: { + list: 'border-neutral-5 border-b', + boxed: 'border-neutral-5 rounded-md border', + }, + }, +}); -export function AccordionContent({ children }: { children: ReactNode }) { +const headerRowVariants = cva('flex items-center', { + variants: { + variant: { + list: '', + boxed: 'px-4', + }, + }, +}); + +const triggerVariants = cva( + cn( + 'group flex w-full items-center gap-2 text-left font-medium transition-colors', + focusRingQuiet, + 'focus-visible:-outline-offset-2', + 'data-[disabled]:pointer-events-none data-[disabled]:opacity-50', + ), + { + variants: { + size: { + default: 'py-3 text-sm', + sm: 'py-2 text-xs', + }, + tone: { + default: 'text-neutral-12 hover:text-neutral-11', + accent: 'text-accent hover:text-accent/80', + }, + }, + }, +); + +const chevronVariants = cva('shrink-0 transition-transform duration-200', { + variants: { + size: { + default: 'size-4', + sm: 'size-3', + }, + tone: { + default: 'text-neutral-8', + accent: 'text-accent', + }, + chevron: { + end: 'ml-auto group-data-[panel-open]:rotate-180', + start: '-rotate-90 group-data-[panel-open]:rotate-0', + none: 'hidden', + }, + }, +}); + +// Base UI measures the panel into a variable, which is what lets the height animate. +const panelVariants = cva( + 'h-[var(--accordion-panel-height)] overflow-hidden text-sm transition-[height] duration-200 ease-out data-[ending-style]:h-0 data-[starting-style]:h-0', +); + +const panelInnerVariants = cva('', { + variants: { + variant: { + list: '', + boxed: 'px-4', + }, + size: { + default: 'pb-4', + sm: 'pb-2', + }, + }, +}); + +export function Accordion({ + items, + value, + defaultValue, + onValueChange, + multiple = false, + variant = 'list', + size = 'default', + chevron = 'end', + tone = 'default', + keepMounted, + attrs, +}: AccordionProps) { + const chevronIcon = ; return ( - -
{children}
-
+ onValueChange?.(next.map(String))} + multiple={multiple} + keepMounted={keepMounted} + className={rootVariants({ variant })} + {...attrs} + > + {items.map(item => ( + +
+ + + {chevron === 'start' ? chevronIcon : null} + {item.label} + {item.trailing != null ? ( + {item.trailing} + ) : null} + {chevron === 'end' ? chevronIcon : null} + + + {item.action != null ?
{item.action}
: null} +
+ +
{item.content}
+
+
+ ))} +
); } diff --git a/packages/web/app/src/components/target/alerts/alert-form.tsx b/packages/web/app/src/components/target/alerts/alert-form.tsx index f6eb7fc719..a76ffb8716 100644 --- a/packages/web/app/src/components/target/alerts/alert-form.tsx +++ b/packages/web/app/src/components/target/alerts/alert-form.tsx @@ -4,12 +4,7 @@ import { Plus, X } from 'lucide-react'; import { useFieldArray, useForm } from 'react-hook-form'; import { useMutation, useQuery } from 'urql'; import { z } from 'zod'; -import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger, -} from '@/components/base/accordion/accordion'; +import { Accordion } from '@/components/base/accordion/accordion'; import { Button } from '@/components/base/button/button'; import { Card } from '@/components/base/card/card'; import { Select } from '@/components/base/floating/select/select'; @@ -870,77 +865,86 @@ export function AlertForm(props: AlertFormProps) { clipToCurrentWindow /> - - - - -
- { - const isLoading = savedFiltersQuery.fetching; - const savedFilterOptions = [ - { - value: '', - label: isLoading - ? 'Loading filters...' - : 'No filter (all operations)', - }, - ...(savedFiltersQuery.data?.target?.savedFilters?.edges?.map(edge => ({ - value: edge.node.id, - label: edge.node.name, - })) ?? []), - ]; - - return ( + + { + const isLoading = savedFiltersQuery.fetching; + const savedFilterOptions = [ + { + value: '', + label: isLoading + ? 'Loading filters...' + : 'No filter (all operations)', + }, + ...(savedFiltersQuery.data?.target?.savedFilters?.edges?.map( + edge => ({ + value: edge.node.id, + label: edge.node.name, + }), + ) ?? []), + ]; + + return ( + + + + 10} - onSurface="raised" - /> + - + + Wait for the condition to exist for{' '} + + {field.value || '0'} + {' '} + minutes before firing. Helps prevent false alarms from brief + spikes. Leave at 0 to fire as soon as the condition holds for + two consecutive evaluations (recommended for alert ranges + greater than 1 day). + + } + /> - ); - }} - /> - ( - - - - - - - Wait for the condition to exist for{' '} - - {field.value || '0'} - {' '} - minutes before firing. Helps prevent false alarms from brief - spikes. Leave at 0 to fire as soon as the condition holds for two - consecutive evaluations (recommended for alert ranges greater than - 1 day). - - } - /> - - )} - /> -
-
-
-
+ )} + /> +
+ ), + }, + ]} + /> From ef5d2be864a483f986661784aaa3f49743e50339 Mon Sep 17 00:00:00 2001 From: Jonathan Brennan Date: Sat, 19 Sep 2026 16:52:40 -0500 Subject: [PATCH 3/8] strip border from last item --- packages/web/app/src/components/base/accordion/accordion.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/web/app/src/components/base/accordion/accordion.tsx b/packages/web/app/src/components/base/accordion/accordion.tsx index 144103957f..06ab1abfb2 100644 --- a/packages/web/app/src/components/base/accordion/accordion.tsx +++ b/packages/web/app/src/components/base/accordion/accordion.tsx @@ -31,7 +31,7 @@ type AccordionProps = { onValueChange?: (value: string[]) => void; /** More than one item open at once. */ multiple?: boolean; - /** `list` draws a hairline under each item; `boxed` gives each item a card border, spaced apart. */ + /** `list` draws a hairline between items; `boxed` gives each item a card border, spaced apart. */ variant?: AccordionVariant; size?: AccordionSize; /** Where the chevron sits. `start` for a disclosure under a form; `none` when the label carries its own icon. */ @@ -55,7 +55,8 @@ const rootVariants = cva('', { const itemVariants = cva('', { variants: { variant: { - list: 'border-neutral-5 border-b', + // Hairlines separate items; a lone disclosure has nothing to separate from. + list: 'border-neutral-5 border-b last:border-b-0', boxed: 'border-neutral-5 rounded-md border', }, }, From 093016c587d036da295c63bd6d89cf25d9ed2781 Mon Sep 17 00:00:00 2001 From: Jonathan Brennan Date: Sat, 19 Sep 2026 16:59:03 -0500 Subject: [PATCH 4/8] move the permission selector, the permission overview and the token permission detail view onto base Accordion --- .../members/permission-selector.tsx | 310 +++++++++--------- .../members/selected-permission-overview.tsx | 83 +++-- .../access-tokens/permission-detail-view.tsx | 94 +++--- 3 files changed, 233 insertions(+), 254 deletions(-) diff --git a/packages/web/app/src/components/organization/members/permission-selector.tsx b/packages/web/app/src/components/organization/members/permission-selector.tsx index 78c0df4360..ff7c012aa9 100644 --- a/packages/web/app/src/components/organization/members/permission-selector.tsx +++ b/packages/web/app/src/components/organization/members/permission-selector.tsx @@ -1,14 +1,9 @@ import { useMemo, useRef, useState } from 'react'; import { InfoIcon, TriangleAlert } from 'lucide-react'; +import { Accordion } from '@/components/base/accordion/accordion'; import { Popover } from '@/components/base/floating/popover/popover'; import { Select } from '@/components/base/floating/select/select'; import type { OnSurface } from '@/components/base/shared-styles'; -import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger, -} from '@/components/ui/accordion'; import { Button } from '@/components/ui/button'; import { FragmentType, graphql, useFragment } from '@/gql'; import { cn } from '@/lib/utils'; @@ -96,184 +91,173 @@ export function PermissionSelector(props: PermissionSelectorProps) { return ( setOpenAccordions(values)} - > - {groups.map(group => { - return ( - - - {group.title}{' '} - - {group.selectedPermissionCount > 0 && ( - - {group.selectedPermissionCount} selected - - )} - - - - {group.permissions.map(permission => { - const needsDependency = - !!permission.dependsOnId && - !props.selectedPermissionIds.has(permission.dependsOnId); + onValueChange={setOpenAccordions} + items={groups.map(group => ({ + value: group.title, + label: group.title, + trailing: + group.selectedPermissionCount > 0 ? ( + {group.selectedPermissionCount} selected + ) : undefined, + content: ( +
+ {group.permissions.map(permission => { + const needsDependency = + !!permission.dependsOnId && + !props.selectedPermissionIds.has(permission.dependsOnId); - return ( -
+ return ( +
+
{ + if (ref) { + permissionRefs.current.set(permission.id, ref); + } + }} + >
{ - if (ref) { - permissionRefs.current.set(permission.id, ref); - } - }} > -
-
{permission.title}
-
{permission.description}
+
{permission.title}
+
{permission.description}
+
+ {permission.isAssignableByViewer === false ? ( +
+ + + + } + openOnHover + content={ +

+ Your membership has insufficient authority for assigning this + permission. +

+ } + /> +
+ ) : permission.warning && props.selectedPermissionIds.has(permission.id) ? ( +
+ + + + } + openOnHover + content={

{permission.warning}

} + />
- {permission.isAssignableByViewer === false ? ( + ) : ( + !!permission.dependsOnId && + permissionToGroupTitleMapping.has(permission.dependsOnId) && (
+ } openOnHover content={

- Your membership has insufficient authority for assigning this - permission. + This permission depends on another permission.{' '} +

} />
- ) : permission.warning && props.selectedPermissionIds.has(permission.id) ? ( -
- - - - } - openOnHover - content={ -

{permission.warning}

- } - /> -
- ) : ( - !!permission.dependsOnId && - permissionToGroupTitleMapping.has(permission.dependsOnId) && ( -
- - - - } - openOnHover - content={ -

- This permission depends on another permission.{' '} - -

- } - /> -
- ) - )} - { + const dependents = dependencyGraph.get(permission.id) ?? []; + if (value === 'allow') { + props.onSelectedPermissionsChange( + new Set([...props.selectedPermissionIds, permission.id]), + ); + } else if (value === 'not-selected') { + const selectedPermissionIds = new Set(props.selectedPermissionIds); + selectedPermissionIds.delete(permission.id); + for (const dependent of dependents) { + selectedPermissionIds.delete(dependent); + } + props.onSelectedPermissionsChange(selectedPermissionIds); + } + setFocusedPermission(null); + }} + width="sm" + />
- ); - })} - - - ); - })} - + {focusedPermission === permission.id && ( +
+ )} +
+ ); + })} +
+ ), + }))} + /> ); } diff --git a/packages/web/app/src/components/organization/members/selected-permission-overview.tsx b/packages/web/app/src/components/organization/members/selected-permission-overview.tsx index c9e1e7c7b4..f93b315fcd 100644 --- a/packages/web/app/src/components/organization/members/selected-permission-overview.tsx +++ b/packages/web/app/src/components/organization/members/selected-permission-overview.tsx @@ -1,11 +1,6 @@ import { useMemo } from 'react'; +import { Accordion } from '@/components/base/accordion/accordion'; import { PermissionTable } from '@/components/organization/permission-table'; -import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger, -} from '@/components/ui/accordion'; import { FragmentType, graphql, useFragment } from '@/gql'; import { PermissionLevelType } from '@/gql/graphql'; import { ResultOf } from '@graphql-typed-document-node/core'; @@ -149,43 +144,43 @@ function PermissionLevelGroup(props: { return ( 0 && props.isExpanded ? props.title : undefined} - collapsible - > - - - {props.title} - {totalAllowedCount} allowed - - - {filteredGroups.map(group => - props.showOnlyAllowedPermissions && group.totalAllowedCount === 0 ? null : ( -
- { - const granted = - props.activePermissionIds.has(permission.id) || permission.isReadOnly; - if (props.showOnlyAllowedPermissions && !granted) { - return []; - } - return [ - { - id: permission.id, - title: permission.title, - granted, - warning: permission.warning, - }, - ]; - })} - /> -
- ), - )} - {props.additionalContent} -
-
-
+ defaultValue={totalAllowedCount > 0 && props.isExpanded ? [props.title] : undefined} + items={[ + { + value: props.title, + label: props.title, + trailing: {totalAllowedCount} allowed, + content: ( +
+ {filteredGroups.map(group => + props.showOnlyAllowedPermissions && group.totalAllowedCount === 0 ? null : ( +
+ { + const granted = + props.activePermissionIds.has(permission.id) || permission.isReadOnly; + if (props.showOnlyAllowedPermissions && !granted) { + return []; + } + return [ + { + id: permission.id, + title: permission.title, + granted, + warning: permission.warning, + }, + ]; + })} + /> +
+ ), + )} + {props.additionalContent} +
+ ), + }, + ]} + /> ); } diff --git a/packages/web/app/src/components/organization/settings/access-tokens/permission-detail-view.tsx b/packages/web/app/src/components/organization/settings/access-tokens/permission-detail-view.tsx index 3433703747..a954eb1e32 100644 --- a/packages/web/app/src/components/organization/settings/access-tokens/permission-detail-view.tsx +++ b/packages/web/app/src/components/organization/settings/access-tokens/permission-detail-view.tsx @@ -1,6 +1,6 @@ +import { Accordion } from '@/components/base/accordion/accordion'; import { Badge } from '@/components/base/badge/badge'; import { PermissionTable } from '@/components/organization/permission-table'; -import * as Accordion from '@/components/ui/accordion'; import { FragmentType, graphql, useFragment } from '@/gql'; import { permissionLevelToResourceName } from './shared-helpers'; @@ -42,52 +42,52 @@ export function PermissionDetailView(props: { ); return ( - 0 ? group.title : undefined} - collapsible - > - - - {group.title} - {totalAllowedCount} allowed - - - {group.resolvedPermissionGroups.map(group => ( -
- ({ - id: permission.permission.id, - title: permission.permission.title, - granted: permission.isGranted, - warning: permission.permission.warning, - }))} - /> + 0 ? [group.title] : undefined} + items={[ + { + value: group.title, + label: group.title, + trailing: {totalAllowedCount} allowed, + content: ( +
+ {group.resolvedPermissionGroups.map(group => ( +
+ ({ + id: permission.permission.id, + title: permission.permission.title, + granted: permission.isGranted, + warning: permission.permission.warning, + }))} + /> +
+ ))} +
+ {group.resolvedResourceIds == null ? ( +

+ Not granted on any {permissionLevelToResourceName(group.level).slice(0, -1)}. +

+ ) : ( + <> +

+ Granted on {permissionLevelToResourceName(group.level)}: +

+
    + {group.resolvedResourceIds.map(id => ( +
  • + +
  • + ))} +
+ + )} +
- ))} -
- {group.resolvedResourceIds == null ? ( -

- Not granted on any {permissionLevelToResourceName(group.level).slice(0, -1)}. -

- ) : ( - <> -

- Granted on {permissionLevelToResourceName(group.level)}: -

-
    - {group.resolvedResourceIds.map(id => ( -
  • - -
  • - ))} -
- - )} -
- - - + ), + }, + ]} + /> ); } From fdcb9a3ac2328722c6dadc01c58f0f23b1bb7f74 Mon Sep 17 00:00:00 2001 From: Jonathan Brennan Date: Sat, 19 Sep 2026 17:05:00 -0500 Subject: [PATCH 5/8] move the schema changes list and the proposal change onto base Accordion, keep the hairline under every list item, and add a plain variant for the alert form's lone disclosure --- .../base/accordion/accordion.preview.tsx | 7 +- .../components/base/accordion/accordion.tsx | 14 +- .../components/target/alerts/alert-form.tsx | 1 + .../target/history/errors-and-changes.tsx | 208 +++++++++--------- .../target/proposals/change-detail.tsx | 31 +-- 5 files changed, 130 insertions(+), 131 deletions(-) diff --git a/packages/web/app/src/components/base/accordion/accordion.preview.tsx b/packages/web/app/src/components/base/accordion/accordion.preview.tsx index 33633d311f..f6efd3d030 100644 --- a/packages/web/app/src/components/base/accordion/accordion.preview.tsx +++ b/packages/web/app/src/components/base/accordion/accordion.preview.tsx @@ -141,12 +141,13 @@ export const Boxed = createPreview(() => ( )); /** - * alerts/alert-form.tsx, Advanced settings: small, the chevron at the start as a disclosure, and - * the accent tone to call it out. + * alerts/alert-form.tsx, Advanced settings: `plain`, so no hairline under a lone disclosure; + * small, the chevron at the start, and the accent tone to call it out. */ export const SmallAccentDisclosure = createPreview(() => (
( export const Playground = createPreview({ controls: controlsFor(Accordion, { - variant: { type: 'radio', options: ['list', 'boxed'], default: 'list' }, + variant: { type: 'radio', options: ['list', 'boxed', 'plain'], default: 'list' }, size: { type: 'radio', options: ['default', 'sm'], default: 'default' }, chevron: { type: 'radio', options: ['end', 'start', 'none'], default: 'end' }, tone: { type: 'radio', options: ['default', 'accent'], default: 'default' }, diff --git a/packages/web/app/src/components/base/accordion/accordion.tsx b/packages/web/app/src/components/base/accordion/accordion.tsx index 06ab1abfb2..cb70723761 100644 --- a/packages/web/app/src/components/base/accordion/accordion.tsx +++ b/packages/web/app/src/components/base/accordion/accordion.tsx @@ -19,7 +19,7 @@ export type AccordionItem = { attrs?: Record; }; -export type AccordionVariant = 'list' | 'boxed'; +export type AccordionVariant = 'list' | 'boxed' | 'plain'; export type AccordionSize = 'default' | 'sm'; export type AccordionChevron = 'end' | 'start' | 'none'; export type AccordionTone = 'default' | 'accent'; @@ -31,7 +31,10 @@ type AccordionProps = { onValueChange?: (value: string[]) => void; /** More than one item open at once. */ multiple?: boolean; - /** `list` draws a hairline between items; `boxed` gives each item a card border, spaced apart. */ + /** + * `list` draws a hairline under each item, so rows read as a list even one at a time; `boxed` + * gives each item a card border, spaced apart; `plain` draws nothing, for a lone disclosure. + */ variant?: AccordionVariant; size?: AccordionSize; /** Where the chevron sits. `start` for a disclosure under a form; `none` when the label carries its own icon. */ @@ -48,6 +51,7 @@ const rootVariants = cva('', { variant: { list: '', boxed: 'flex flex-col gap-4', + plain: '', }, }, }); @@ -55,9 +59,9 @@ const rootVariants = cva('', { const itemVariants = cva('', { variants: { variant: { - // Hairlines separate items; a lone disclosure has nothing to separate from. - list: 'border-neutral-5 border-b last:border-b-0', + list: 'border-neutral-5 border-b', boxed: 'border-neutral-5 rounded-md border', + plain: '', }, }, }); @@ -67,6 +71,7 @@ const headerRowVariants = cva('flex items-center', { variant: { list: '', boxed: 'px-4', + plain: '', }, }, }); @@ -120,6 +125,7 @@ const panelInnerVariants = cva('', { variant: { list: '', boxed: 'px-4', + plain: '', }, size: { default: 'pb-4', diff --git a/packages/web/app/src/components/target/alerts/alert-form.tsx b/packages/web/app/src/components/target/alerts/alert-form.tsx index a76ffb8716..2b3e31ef88 100644 --- a/packages/web/app/src/components/target/alerts/alert-form.tsx +++ b/packages/web/app/src/components/target/alerts/alert-form.tsx @@ -866,6 +866,7 @@ export function AlertForm(props: AlertFormProps) { /> - - - +
- - - - {change.approval && ( - - )} - {'usageStatistics' in change && change.usageStatistics && metadata ? ( -
-

- Affected Operations (based on usage) -

-
- - Top 10 operations and clients affected by this change based on usage data. - - {metadata && ( - - See{' '} - {metadata.settings.targets.map((target, index, arr) => ( - <> - {!target.target ? ( - - ) : ( - - {target.slug} - - )} - {index === arr.length - 1 - ? null - : index === arr.length - 2 - ? ' and ' - : ', '} - - ))}{' '} - target insights for live usage data. - - )} -
- - {'affectedAppDeployments' in change && - change.affectedAppDeployments?.edges?.length ? ( -
- + {change.approval && ( + + )} + {'usageStatistics' in change && change.usageStatistics && metadata ? ( +
+

+ Affected Operations (based on usage) +

+
+ + Top 10 operations and clients affected by this change based on usage data. + + {metadata && ( + + See{' '} + {metadata.settings.targets.map((target, index, arr) => ( + <> + {!target.target ? ( + + ) : ( + + {target.slug} + + )} + {index === arr.length - 1 + ? null + : index === arr.length - 2 + ? ' and ' + : ', '} + + ))}{' '} + target insights for live usage data. + + )} +
+ + {'affectedAppDeployments' in change && + change.affectedAppDeployments?.edges?.length ? ( +
+ +
+ ) : null}
- ) : null} + ) : 'affectedAppDeployments' in change && + change.affectedAppDeployments?.edges?.length ? ( + + ) : ( + <> + {change.severityReason ?? + `No details available for this ${ + change.severityLevel === SeverityLevelType.Breaking ? 'breaking ' : '' + }change.`} + + )}
- ) : 'affectedAppDeployments' in change && change.affectedAppDeployments?.edges?.length ? ( - - ) : ( - <> - {change.severityReason ?? - `No details available for this ${ - change.severityLevel === SeverityLevelType.Breaking ? 'breaking ' : '' - }change.`} - - )} - - - + ), + }, + ]} + /> ); } diff --git a/packages/web/app/src/components/target/proposals/change-detail.tsx b/packages/web/app/src/components/target/proposals/change-detail.tsx index fefb63431c..dde72ca3ea 100644 --- a/packages/web/app/src/components/target/proposals/change-detail.tsx +++ b/packages/web/app/src/components/target/proposals/change-detail.tsx @@ -1,13 +1,7 @@ import { ReactNode } from 'react'; +import { Accordion } from '@/components/base/accordion/accordion'; import { Popover } from '@/components/base/floating/popover/popover'; -import { - AccordionContent, - AccordionHeader, - AccordionItem, - AccordionTrigger, -} from '@/components/ui/accordion'; import { Button } from '@/components/ui/button'; -import { Accordion } from '@/components/v2'; import { MergeStatus } from '@/pages/target-proposal-details'; import type { Change } from '@graphql-inspector/core'; import { ComponentNoneIcon, ExclamationTriangleIcon, InfoCircledIcon } from '@radix-ui/react-icons'; @@ -19,21 +13,20 @@ export function ProposalChangeDetail(props: { icon?: ReactNode; }) { return ( - - - - -
+
{labelize(props.change.message)}
{props.icon}
-
-
- - {props.error?.message ?? <>No details available for this change.} - -
-
+ ), + content: props.error?.message ?? 'No details available for this change.', + }, + ]} + /> ); } From d2bbaa2f4148d2df78c1bb16b49b30d86aa1c717 Mon Sep 17 00:00:00 2001 From: Jonathan Brennan Date: Sat, 19 Sep 2026 17:07:38 -0500 Subject: [PATCH 6/8] move the registry token section and the target services onto base Accordion, and give a lone service a Card instead of an accordion locked open --- .../target/settings/registry-access-token.tsx | 57 ++++++++++--------- packages/web/app/src/pages/target.tsx | 56 ++++++++++-------- 2 files changed, 62 insertions(+), 51 deletions(-) diff --git a/packages/web/app/src/components/target/settings/registry-access-token.tsx b/packages/web/app/src/components/target/settings/registry-access-token.tsx index 73dfd56828..9b52b411f5 100644 --- a/packages/web/app/src/components/target/settings/registry-access-token.tsx +++ b/packages/web/app/src/components/target/settings/registry-access-token.tsx @@ -2,6 +2,7 @@ import { useState } from 'react'; import { useForm, UseFormReturn } from 'react-hook-form'; import { useMutation } from 'urql'; import { z } from 'zod'; +import { Accordion } from '@/components/base/accordion/accordion'; import { Input } from '@/components/base/input/input'; import { Dialog } from '@/components/base/overlays/dialog/dialog'; import { useToast } from '@/components/base/toast/toast'; @@ -10,7 +11,6 @@ import { Button } from '@/components/ui/button'; import { Callout } from '@/components/ui/callout'; import { Form, FormControl, FormField, FormItem, FormMessage } from '@/components/ui/form'; import { InputCopy } from '@/components/ui/input-copy'; -import { Accordion } from '@/components/v2/accordion'; import { graphql } from '@/gql'; import { TargetAccessScope } from '@/gql/graphql'; import { RegistryAccessScope } from '@/lib/access/common'; @@ -228,31 +228,36 @@ export function GenerateTokenContent(props: { )} /> - - - Registry & Usage - - true} - onChange={value => { - if (value === 'no-access') { - props.setSelectedScope('no-access'); - return; - } - props.setSelectedScope(value); - }} - possibleScope={Object.values(RegistryAccessScope.mapping)} - initialScope={props.selectedScope} - selectedScope={props.selectedScope} - /> - - - + true} + onChange={value => { + if (value === 'no-access') { + props.setSelectedScope('no-access'); + return; + } + props.setSelectedScope(value); + }} + possibleScope={Object.values(RegistryAccessScope.mapping)} + initialScope={props.selectedScope} + selectedScope={props.selectedScope} + /> + ), + }, + ]} + />
, + triggerAttrs: { 'data-cy': 'row-a-trigger' }, }, ]} />, ); const trigger = screen.getByRole('button', { name: /Breaking/ }); expect(trigger.textContent).toContain('3 selected'); + expect(trigger.getAttribute('data-cy')).toBe('row-a-trigger'); const more = screen.getByRole('button', { name: 'More' }); expect(trigger.contains(more)).toBe(false); expect(document.querySelector('[data-cy="row-a"]')!.contains(more)).toBe(true); diff --git a/packages/web/app/src/components/base/accordion/accordion.tsx b/packages/web/app/src/components/base/accordion/accordion.tsx index cb70723761..9dc3e8fc5c 100644 --- a/packages/web/app/src/components/base/accordion/accordion.tsx +++ b/packages/web/app/src/components/base/accordion/accordion.tsx @@ -17,6 +17,8 @@ export type AccordionItem = { disabled?: boolean; /** Test hooks and the like, landed on the item. */ attrs?: Record; + /** Test hooks on the trigger button itself. */ + triggerAttrs?: Record; }; export type AccordionVariant = 'list' | 'boxed' | 'plain'; @@ -168,7 +170,10 @@ export function Accordion({ >
- + {chevron === 'start' ? chevronIcon : null} {item.label} {item.trailing != null ? ( diff --git a/packages/web/app/src/lib/hooks/laboratory/use-operation-collections-plugin.tsx b/packages/web/app/src/lib/hooks/laboratory/use-operation-collections-plugin.tsx index 821b53ff0a..f2f52970a9 100644 --- a/packages/web/app/src/lib/hooks/laboratory/use-operation-collections-plugin.tsx +++ b/packages/web/app/src/lib/hooks/laboratory/use-operation-collections-plugin.tsx @@ -1,7 +1,8 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useState } from 'react'; import clsx from 'clsx'; import { FolderIcon, FolderOpenIcon, SquareTerminalIcon } from 'lucide-react'; import { useMutation, useQuery } from 'urql'; +import { Accordion } from '@/components/base/accordion/accordion'; import { Menu } from '@/components/base/floating/menu/menu'; import { Tooltip } from '@/components/base/floating/tooltip/tooltip'; import { useToast } from '@/components/base/toast/toast'; @@ -9,13 +10,6 @@ import { CreateCollectionModal } from '@/components/target/laboratory/create-col import { DeleteCollectionModal } from '@/components/target/laboratory/delete-collection-modal'; import { DeleteOperationModal } from '@/components/target/laboratory/delete-operation-modal'; import { EditOperationModal } from '@/components/target/laboratory/edit-operation-modal'; -import { - Accordion, - AccordionContent, - AccordionHeader, - AccordionItem, - AccordionTriggerPrimitive, -} from '@/components/ui/accordion'; import { Button } from '@/components/ui/button'; import { PlusIcon } from '@/components/ui/icon'; import { Link } from '@/components/ui/link'; @@ -31,6 +25,9 @@ import { useCollections } from './use-collections'; import { useCurrentOperation } from './use-current-operation'; import { useSyncOperationState } from './use-sync-operation-state'; +// The accordion hands out no ref, so the operation opened from the URL is found by id instead. +const COLLECTIONS_ID = 'laboratory-collections'; + const CreateOperationMutation = graphql(` mutation CreateOperation( $selector: TargetSelectorInput! @@ -144,7 +141,6 @@ export function Content() { }); const router = useRouter(); const [accordionValue, setAccordionValue] = useState([]); - const containerRef = useRef(null); const [isScrolled, setIsScrolled] = useState(false); const copyToClipboard = useClipboard(); @@ -289,7 +285,9 @@ export function Content() { setAccordionValue([initialSelectedCollection]); setTimeout(() => { - const link = containerRef.current!.querySelector(`a[href$="${queryParamsOperationId}"]`); + const link = document + .getElementById(COLLECTIONS_ID) + ?.querySelector(`a[href$="${queryParamsOperationId}"]`); if (link) { link.scrollIntoView(); @@ -298,64 +296,67 @@ export function Content() { }, 150); }, [initialSelectedCollection]); - const renderedCollections = collections.map(collection => ( - - - - - - {collection.name} - - {shouldShowMenu && ( - - - - } - sections={[ - [ - { - label: 'Add operation', - trailingIcon: PlusIcon, - onClick: () => void addOperation(collection.id), - disabled: createOperationState.fetching, - attrs: { 'data-cy': 'add-operation-to-collection' }, - }, - ], - [ - { - label: 'Edit', - onClick: () => { - setCollectionId(collection.id); - toggleCollectionModal(); - }, - attrs: { 'data-cy': 'edit-collection' }, - }, - { - label: 'Delete', - variant: 'destructiveAction', - onClick: () => { - setCollectionId(collection.id); - toggleDeleteCollectionModalOpen(); - }, - attrs: { 'data-cy': 'delete-collection' }, - }, - ], - ]} - /> + const renderedCollections = collections.map(collection => ({ + value: collection.id, + label: ( + + {accordionValue.includes(collection.id) ? ( + + ) : ( + )} - - + {collection.name} + + ), + attrs: { 'data-cy': 'collection-item' }, + triggerAttrs: { 'data-cy': 'collection-item-trigger' }, + action: shouldShowMenu ? ( + + + + } + sections={[ + [ + { + label: 'Add operation', + trailingIcon: PlusIcon, + onClick: () => void addOperation(collection.id), + disabled: createOperationState.fetching, + attrs: { 'data-cy': 'add-operation-to-collection' }, + }, + ], + [ + { + label: 'Edit', + onClick: () => { + setCollectionId(collection.id); + toggleCollectionModal(); + }, + attrs: { 'data-cy': 'edit-collection' }, + }, + { + label: 'Delete', + variant: 'destructiveAction', + onClick: () => { + setCollectionId(collection.id); + toggleDeleteCollectionModalOpen(); + }, + attrs: { 'data-cy': 'delete-collection' }, + }, + ], + ]} + /> + ) : undefined, + content: ( +
{collection.operations.edges.length ? ( collection.operations.edges.map(({ node }) => (
@@ -433,9 +434,9 @@ export function Content() { Add Operation )} - - - )); +
+ ), + })); const target = query.data?.target; @@ -476,13 +477,14 @@ export function Content() {
) : collections.length ? ( - {renderedCollections} - + items={renderedCollections} + attrs={{ id: COLLECTIONS_ID }} + /> ) : (
From 098303778e1f150cb48f1c67e32f2a113aa44f41 Mon Sep 17 00:00:00 2001 From: Jonathan Brennan Date: Sat, 19 Sep 2026 17:23:19 -0500 Subject: [PATCH 8/8] delete ui/accordion, v2/accordion and @radix-ui/react-accordion, and render the accordion call-site preview on base Accordion --- packages/web/app/package.json | 1 - .../accordion.call-sites.preview.tsx | 481 +++++++++--------- .../web/app/src/components/ui/accordion.tsx | 59 --- .../web/app/src/components/v2/accordion.tsx | 103 ---- packages/web/app/src/components/v2/index.ts | 1 - pnpm-lock.yaml | 351 ++++++------- 6 files changed, 388 insertions(+), 608 deletions(-) delete mode 100644 packages/web/app/src/components/ui/accordion.tsx delete mode 100644 packages/web/app/src/components/v2/accordion.tsx diff --git a/packages/web/app/package.json b/packages/web/app/package.json index a1d7138c13..cfe11b897e 100644 --- a/packages/web/app/package.json +++ b/packages/web/app/package.json @@ -33,7 +33,6 @@ "@hookform/resolvers": "5.9.1", "@monaco-editor/react": "4.8.0-rc.2", "@pierre/diffs": "1.3.5", - "@radix-ui/react-accordion": "1.2.2", "@radix-ui/react-icons": "1.3.2", "@radix-ui/react-label": "2.1.1", "@radix-ui/react-slot": "1.1.1", diff --git a/packages/web/app/src/components/base/accordion/accordion.call-sites.preview.tsx b/packages/web/app/src/components/base/accordion/accordion.call-sites.preview.tsx index 845826678d..db9c80f602 100644 --- a/packages/web/app/src/components/base/accordion/accordion.call-sites.preview.tsx +++ b/packages/web/app/src/components/base/accordion/accordion.call-sites.preview.tsx @@ -2,93 +2,87 @@ import { useState } from 'react'; import { FolderIcon, FolderOpenIcon } from 'lucide-react'; import { createPreview, type NavPath } from 'react-foundry'; import { Badge } from '@/components/base/badge/badge'; +import { Card } from '@/components/base/card/card'; import { Menu } from '@/components/base/floating/menu/menu'; import { Select } from '@/components/base/floating/select/select'; import { CallSite, InventoryList } from '@/components/inventory/shared'; import { PermissionTable } from '@/components/organization/permission-table'; -import { - Accordion, - AccordionContent, - AccordionHeader, - AccordionItem, - AccordionTrigger, - AccordionTriggerPrimitive, -} from '@/components/ui/accordion'; import { PulseIcon } from '@/components/ui/icon'; -import { Accordion as V2Accordion } from '@/components/v2/accordion'; import { CheckIcon, DotsHorizontalIcon, ExclamationTriangleIcon, PlusIcon, } from '@radix-ui/react-icons'; +import { Accordion } from './accordion'; export const nav: NavPath = 'Base/Primitives/Accordion/Component Examples'; /** - * Every accordion in the app as it ships today, on `ui/accordion` (Radix, shadcn parts) and - * `v2/accordion` (Radix, compound `Accordion.Item/Header/Content`). Nine mounts in eight files. - * The pages around them run queries and mutations, so the content inside each item is stood in - * where it would need one: the schema highlighter is a `
`, the change details a paragraph,
+ * Every accordion in the app, transcribed as it ships on base Accordion. Nine mounts in eight
+ * files. The pages around them run queries and mutations, so the content inside each item is stood
+ * in where it would need one: the schema highlighter is a `
`, the change details a paragraph,
  * the approval badge a Badge.
  *
- * Read with `base/accordion`, which exists (Base UI) and is on one call site, the alert rule
- * form's Advanced settings. What these sites use that it does not offer is the round's API work.
+ * History: until round 7 six of these were on `ui/accordion` (Radix, shadcn parts) and three on
+ * `v2/accordion` (Radix, compound parts, always collapsible), one site mixing the two. A single
+ * service on the target page was an accordion locked open with its trigger disabled; it is a Card
+ * now. Both old components and `@radix-ui/react-accordion` are deleted.
  */
 
 const ENTRIES = [
   {
-    source: 'components/organization/members/permission-selector.tsx:98',
-    origin: 'ui',
-    what: 'Permission groups on the role form: multiple open, controlled, every panel kept mounted, "N selected" at the trigger end',
+    source: 'components/organization/members/permission-selector.tsx:93',
+    origin: 'base',
+    what: 'Permission groups on the role form: multiple open, controlled, panels kept mounted for the dependency jump, "N selected" trailing',
     coveredBy: 'Permission selector',
   },
   {
-    source: 'components/organization/members/selected-permission-overview.tsx:151',
-    origin: 'ui',
-    what: 'A role\'s granted permissions: one item, open by default when anything is granted, "N allowed" at the trigger end',
+    source: 'components/organization/members/selected-permission-overview.tsx:146',
+    origin: 'base',
+    what: 'A role\'s granted permissions: one item, open by default when anything is granted, "N allowed" trailing',
     coveredBy: 'Permission overview',
   },
   {
     source: 'components/organization/settings/access-tokens/permission-detail-view.tsx:45',
-    origin: 'ui',
+    origin: 'base',
     what: "An access token's permissions per level, the same shape plus the resources it was granted on",
     coveredBy: 'Permission overview',
   },
   {
-    source: 'components/target/history/errors-and-changes.tsx:233',
-    origin: 'ui',
-    what: 'One accordion per schema change on the check, version and proposal pages: a rich trigger, details below',
+    source: 'components/target/history/errors-and-changes.tsx:227',
+    origin: 'base',
+    what: 'One accordion per schema change on the check, version and proposal pages: a rich label, details below',
     coveredBy: 'Schema changes',
   },
   {
-    source: 'components/target/proposals/change-detail.tsx:22',
-    origin: 'v2',
-    what: 'A proposal change: v2 root around ui parts, message and an icon in the trigger',
+    source: 'components/target/proposals/change-detail.tsx:20',
+    origin: 'base',
+    what: 'A proposal change: dimmed message and an icon in the label',
     coveredBy: 'Proposal change',
   },
   {
     source: 'components/target/settings/registry-access-token.tsx:231',
-    origin: 'v2',
-    what: "The registry token form's permission section, open by default",
+    origin: 'base',
+    what: "The registry token form's permission section, plain and open by default",
     coveredBy: 'Registry token',
   },
   {
-    source: 'pages/target.tsx:74',
-    origin: 'v2',
-    what: 'The services of a federation target, each a boxed item with a two-line header and the SDL inside',
+    source: 'pages/target.tsx:67',
+    origin: 'base',
+    what: 'The services of a federation target, boxed, a two-line header and the SDL inside',
     coveredBy: 'Target services',
   },
   {
-    source: 'pages/target.tsx:94',
-    origin: 'v2',
-    what: 'A single service: the same item, always open, trigger disabled',
+    source: 'pages/target.tsx:86',
+    origin: 'base',
+    what: 'A single service: a Card with the same header and SDL, since there is nothing to collapse',
     coveredBy: 'Target services',
   },
   {
-    source: 'lib/hooks/laboratory/use-operation-collections-plugin.tsx:302',
-    origin: 'ui',
-    what: 'Laboratory collections: multiple open, controlled, a raw trigger with folder icons and a menu beside it, e2e hooks',
+    source: 'lib/hooks/laboratory/use-operation-collections-plugin.tsx:301',
+    origin: 'base',
+    what: 'Laboratory collections: plain, no chevron, folder icons in the label, a menu through action, e2e hooks on item and trigger',
     coveredBy: 'Laboratory collections',
   },
 ] as const;
@@ -97,13 +91,12 @@ export const Inventory = createPreview({
   label: 'Inventory',
   render: () => (
     
-          Nine mounts. Six on ui/accordion, which is Radix with a chevron trigger and a bordered
-          item; three on v2/accordion, which is Radix with a boxed header and its own chevron, and
-          always collapsible. One site mixes the two. Base already has an Accordion on Base UI with
-          numeric values and a text-only trigger, used once.
+          Nine mounts, all on base Accordion. Three of the four variants ship: list on the
+          permission views and the change rows, boxed on the target services, plain on the alert
+          form's disclosure, the registry token section and the collections sidebar.
         
       }
       entries={ENTRIES}
@@ -112,7 +105,7 @@ export const Inventory = createPreview({
 });
 
 // ---------------------------------------------------------------------------
-// components/organization/members/permission-selector.tsx:98
+// components/organization/members/permission-selector.tsx:93
 // ---------------------------------------------------------------------------
 
 const PERMISSION_GROUPS = [
@@ -165,33 +158,26 @@ function PermissionSelector() {
   const [selected, setSelected] = useState(new Set(['organization:describe', 'project:describe']));
   return (
      setOpenAccordions(values)}
-    >
-      {PERMISSION_GROUPS.map(group => {
+      onValueChange={setOpenAccordions}
+      items={PERMISSION_GROUPS.map(group => {
         const count = group.permissions.filter(p => selected.has(p.id)).length;
-        return (
-          
-            
-              {group.title}{' '}
-              
-                {count > 0 && {count} selected}
-              
-            
-            
+        return {
+          value: group.title,
+          label: group.title,
+          trailing: count > 0 ? {count} selected : undefined,
+          content: (
+            
{group.permissions.map(permission => (
{permission.title}
-
{permission.description}
+
{permission.description}
-