Nxt 4643 Create/rework container components#436
Conversation
|
There was a problem hiding this comment.
Pull request overview
This PR expands the menu/container infrastructure in the KNIME Design System by extracting shared list/menu behaviors into composables, introducing new menu APIs (custom toggler + context menu), and extending popover/list-item capabilities to support these use cases.
Changes:
- Added
KdsMenu(custom toggler via slot) andKdsContextMenu(custom-positioned menu) components. - Refactored keyboard navigation + stable DOM id behavior into shared composables and reused them in
KdsListContainerandKdsMenuContainer. - Extended
KdsPopoverwith a"custom"placement mode and extendedKdsListItemwith trailing badges.
Reviewed changes
Copilot reviewed 24 out of 24 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/components/src/overlays/Popover/types.ts | Adds customPlacementPosition prop documentation/type for custom popover placement. |
| packages/components/src/overlays/Popover/enums.ts | Adds "custom" to popover placement options. |
| packages/components/src/overlays/Popover/KdsPopover.vue | Implements custom placement behavior and runtime warnings. |
| packages/components/src/index.ts | Re-exports containers from the package root. |
| packages/components/src/containers/index.ts | Updates container public exports (Menu added; ContextMenu currently not exported). |
| packages/components/src/containers/_helpers/useStableItemIds.ts | New shared helper for deduplication + stable prefixed DOM ids. |
| packages/components/src/containers/_helpers/useListItemKeyboardNav.ts | New shared helper for list/menu keyboard navigation + active descendant behavior. |
| packages/components/src/containers/MenuContainer/types.ts | Extends menu item model (badge, shortcut, handler) and adds a container-wide size variant. |
| packages/components/src/containers/MenuContainer/KdsMenuItem.vue | New internal renderer component for menu items (headline vs selectable item). |
| packages/components/src/containers/MenuContainer/KdsMenuContainer.vue | Refactors MenuContainer to use new composables and adds variant support. |
| packages/components/src/containers/MenuContainer/KdsMenuContainer.stories.ts | Adds Storybook coverage for MenuContainer behaviors and variants. |
| packages/components/src/containers/Menu/types.ts | Defines KdsMenuProps (currently aliased to KdsMenuContainerProps). |
| packages/components/src/containers/Menu/index.ts | Exports the new KdsMenu component. |
| packages/components/src/containers/Menu/KdsMenu.vue | New Menu component wrapping Popover + MenuContainer with a slot-based toggler API. |
| packages/components/src/containers/Menu/KdsMenu.stories.ts | Adds Storybook story + play test for KdsMenu. |
| packages/components/src/containers/ListItem/KdsListItem/types.ts | Adds badge prop and clarifies trailing-item mutual exclusivity. |
| packages/components/src/containers/ListItem/KdsListItem/KdsListItem.vue | Implements badge rendering and trailing-item precedence logic. |
| packages/components/src/containers/ListContainer/tests/KdsListContainer.test.ts | Adjusts duplicate-id warning assertion to be less brittle. |
| packages/components/src/containers/ListContainer/KdsListContainer.vue | Refactors to use the new shared composables for ids + keyboard navigation. |
| packages/components/src/containers/ListContainer/KdsListContainer.stories.ts | Updates story expectations for allow-no-selection Enter behavior. |
| packages/components/src/containers/ContextMenu/types.ts | Adds props for context menu positioning + popover mode. |
| packages/components/src/containers/ContextMenu/index.ts | Exports the new KdsContextMenu component (local barrel). |
| packages/components/src/containers/ContextMenu/KdsContextMenu.vue | New ContextMenu component using KdsPopover custom placement + KdsMenuContainer. |
| packages/components/src/containers/ContextMenu/KdsContextMenu.stories.ts | Adds Storybook story scaffolding for context menu usage. |
| const anchorStyle = { | ||
| "anchor-name": placement === kdsPopoverPlacement.CUSTOM ? "" : anchorName, | ||
| }; |
There was a problem hiding this comment.
For placement === "custom", anchorStyle sets anchor-name to an empty string. This still applies a CSS property with an invalid/meaningless value on the anchor element. Consider returning an empty style object for the custom case (or omitting anchor-name) so consumers don’t apply a broken anchor-name declaration.
| const anchorStyle = { | |
| "anchor-name": placement === kdsPopoverPlacement.CUSTOM ? "" : anchorName, | |
| }; | |
| const anchorStyle = | |
| placement === kdsPopoverPlacement.CUSTOM | |
| ? {} | |
| : { "anchor-name": anchorName }; |
| /** | ||
| * This is only valid and accounted for when the `placement` is of type `custom`. Supplying this will | ||
| * position the popover statically on the position stated in this value. | ||
| */ | ||
| customPlacementPosition?: { x: number; y: number }; |
There was a problem hiding this comment.
customPlacementPosition is only meaningful when placement is "custom", but the current prop typing allows invalid combinations and relies on runtime warnings. Consider making KdsPopoverProps a discriminated union so TS enforces that customPlacementPosition is required for placement: "custom" and disallowed otherwise.
There was a problem hiding this comment.
@ChristianAlbrecht I wanted to try this even before copilot suggested it but I decided against it because I remember you mentioned that discriminated unions don't work that well for props. Would you be ok with the current approach?
| <KdsMenuContainer | ||
| :id="menuId" | ||
| ref="menuContainer" | ||
| :items="props.items" | ||
| :menu-max-height="menuMaxHeight" |
There was a problem hiding this comment.
KdsMenu accepts ...props from KdsMenuProps but doesn’t forward ariaLabel (and ignores any provided id) when rendering KdsMenuContainer, so the rendered menu will always use KdsMenuContainer’s default aria-label (currently "Actions"). Forward ariaLabel/id (or remove them from the public props type) so consumers can set accessible naming and stable IDs.
There was a problem hiding this comment.
I'll remove id and ariaLabel from the props as those should only be internally controlled in the case for this component
| const open = defineModel<boolean>({ default: false }); | ||
|
|
||
| const onItemClick = (itemId: string) => { | ||
| open.value = false; | ||
| emit("itemClick", itemId); | ||
| }; |
There was a problem hiding this comment.
KdsContextMenu opens/closes the popover but never moves focus into the menu when open becomes true. Other menu-like components (e.g. KdsMenu, KdsMenuButton, KdsSplitButton) focus the menu container on open so keyboard navigation works immediately. Add a watch(open, ...) + nextTick() to focus the inner KdsMenuContainer when the context menu opens (and ensure the container ref is available).
There was a problem hiding this comment.
I intentionally left this out. Moving the focus automatically for the MenuContainer (used by the ContextMenu) also means focusing the first item, which makes it visually stand out (as if you'd hover), which is obviously necessary when doing keyboard nav. However, opening the menu with the mouse makes this feel rather weird because you get an item that's automatically highlighted for no apparent reason.
There was a problem hiding this comment.
But if you do a focus via code and its visually highlighted then the impl is wrong. It should use :focus-visible istead of :focus to style the stuff.
There was a problem hiding this comment.
@carlos22 the thing is I took all of that logic from an existing component (ListContainer) and extracted it into a composable. I didn't want to modify it too much to not break the other component and not go further beyond scope. I can try to see if it's an easy fix, let's see...
There was a problem hiding this comment.
Ok got that, but I would still do the focus and then fix it with the different pseudo class if it is annoying (which it might be).
There was a problem hiding this comment.
@carlos22 @ChristianAlbrecht I've been playing around a bit with the keyboard nav code and I think it's best to have its own separate PR for this
| const anchorStyle = { | ||
| "anchor-name": placement === kdsPopoverPlacement.CUSTOM ? "" : anchorName, | ||
| }; | ||
|
|
||
| const onToggle = (event: ToggleEvent) => { | ||
| open.value = event.newState === "open"; | ||
| }; | ||
|
|
||
| defineExpose<KdsPopoverExpose>({ anchorStyle, popoverId }); | ||
|
|
||
| const wrapperStyles = computed(() => { | ||
| if ( | ||
| placement === kdsPopoverPlacement.CUSTOM && | ||
| props.customPlacementPosition | ||
| ) { | ||
| return { | ||
| "max-inline-size": maxInlineSize, | ||
| left: `${props.customPlacementPosition.x}px`, | ||
| top: `${props.customPlacementPosition.y}px`, | ||
| }; | ||
| } | ||
|
|
||
| return { | ||
| "position-anchor": anchorName, | ||
| "max-inline-size": maxInlineSize, | ||
| }; | ||
| }); |
There was a problem hiding this comment.
KdsPopover adds a new placement="custom" + customPlacementPosition code path, but the Popover story file doesn’t include a story (or play assertions) that renders the custom placement and verifies that the popover positions correctly and that anchorStyle behaves as expected (e.g. not setting an anchor-name). Since Storybook play tests contribute to coverage here, please add a dedicated story for the custom placement path.
7482155 to
98e9afb
Compare
98e9afb to
fd34c34
Compare
There was a problem hiding this comment.
There are no code changes in this composable, other than using the options arguments and the callback functions in the triggers as opposed to emitting directly as it used to when it was component code, and one small change (see comment below)
Other logic is the same as before
| event.preventDefault(); | ||
| break; | ||
| case "Enter": | ||
| if (activeId.value && targetItem) { |
There was a problem hiding this comment.
I changed this approach from the prev impl. Before, it used to emit an event with an undefined id when hitting enter but nothing was focused, which was weird and didn't seem to enable any use-case I could think of
|
| const wrapperStyles = computed(() => { | ||
| if ( | ||
| placement === kdsPopoverPlacement.CUSTOM && | ||
| props.customPlacementPosition | ||
| ) { |
There was a problem hiding this comment.
placement: "custom" / customPlacementPosition is a new behavior path in KdsPopover, but the existing KdsPopover.stories.ts doesn’t appear to include any story that sets placement="custom" (and validates positioning/light-dismiss). Please add Storybook coverage for the custom placement mode so this code path is exercised by the Storybook test suite and visual snapshots.
| return { | ||
| "max-inline-size": maxInlineSize, | ||
| left: `${props.customPlacementPosition.x}px`, | ||
| top: `${props.customPlacementPosition.y}px`, | ||
| }; |
There was a problem hiding this comment.
For placement: "custom", the inline styles only set left/top (and max size). Native popovers typically have default inset values; if right/bottom remain set (e.g. via inset: 0), the popover can stretch across the viewport, which breaks light-dismiss (“click outside”) and can lead to an unexpectedly large click-interception area. Consider explicitly clearing inset (e.g. inset: auto or right/bottom: auto) and setting the intended positioning mode (typically position: fixed) in the custom branch.
| // Export container components | ||
| export * from "./containers"; | ||
| export type * from "./containers"; |
There was a problem hiding this comment.
This PR introduces new public exports (containers) and new/extended public APIs (e.g. Popover placement: "custom", KdsMenu, KdsContextMenu). There doesn’t appear to be any Changeset added (the .changeset/ folder only contains config.json), so the release tooling won’t capture these user-facing changes. Please add an appropriate changeset (likely minor if any breaking API surface changed, otherwise patch).
| export const Default: Story = { | ||
| render: (args) => ({ | ||
| components: { KdsMenu, KdsAvatar }, | ||
| setup() { | ||
| return { args }; |
There was a problem hiding this comment.
This story file only defines a Default story. To align with the repo’s Storybook testing/coverage approach, please add the standard TextOverflow, DesignComparator, and AllCombinations stories (or explicitly document why one of them doesn’t apply for this slot-based wrapper component). Without them, important prop/state combinations (e.g. variant, menuMaxHeight, open/closed rendering) won’t be systematically covered by Storybook tests/snapshots.
| export const Default: Story = { | ||
| render: (args) => ({ | ||
| components: { KdsContextMenu, KdsButton }, | ||
| setup() { | ||
| const open = ref(false); | ||
| const position = ref<{ x: number; y: number }>(); | ||
| const openOnPosition = (event: MouseEvent) => { | ||
| const { clientX, clientY } = event; | ||
| position.value = { x: clientX, y: clientY }; |
There was a problem hiding this comment.
This story file only defines a Default story. Please add the standard TextOverflow, DesignComparator, and AllCombinations stories (or explicitly note why any of them don’t apply) so the component’s rendering and prop combinations are covered by Storybook tests/snapshots (e.g. popoverMode, variant, menu content sizes).
| argTypes: { | ||
| id: { | ||
| control: "text", | ||
| table: { category: "props" }, | ||
| }, |
There was a problem hiding this comment.
variant and menuMaxHeight are props of KdsMenuContainer, but they’re not included in argTypes / args here. That makes the Controls panel and autodocs incomplete and can hide these supported options from users. Consider adding both props to argTypes (category props) and providing explicit defaults in args.
| /** | ||
| * Badge to display on the item. This can only be displayed if both `shortcut` and `trailingIcon` are **NOT** supplied | ||
| */ | ||
| badge?: { | ||
| label: string; |
There was a problem hiding this comment.
badge adds a new rendering branch in KdsListItem, but there are currently no Storybook stories exercising it (searching the existing SmallListItem.stories.ts / LargeListItem.stories.ts shows no badge usage). Please add at least one story (and/or include it in AllCombinations) to cover the badge case so the new UI and the shortcut/icon precedence logic are covered by Storybook tests and visual snapshots.



This PR introduces the following changes:
MenuContainer:This now became more of a "base" component, which provides behaviors for many other components. It changed like so:
ListContaineranymore, which use to couple it to its implementation. Instead, it reuses behaviors via composables. This is also helpful later on to implement features like nested submenus, which involve using recursive components. Had this component kept using theListContainerthen all that complexity would been added to it unnecessarily(1) the badge is meant to be displayed at the right end, unlike the accessories
(2) you can have items with both an accessory (e.g avatar) and a badge. So making the badge an accessory wouldn't allow this.
itemsprop can receive a handler function which runs when the item is interacted with, whether that is via clicks or keyboard events. Although, the older general event (itemClicked) was kept for backwards compatibility since that's used by the SplitButtonListContainer:ListItem:Menu:MenuButtonwhich only works with aKdsButton. This component is needed for cases where we want to implement togglers that do not necessarily should live in KDS. For example, clicking an account avatar can open a menu, but having a MenuButtonAvatar doesn't make sense, specially if there's very little cases like this.ContextMenu:Menuthis satisfies a different use-case; one where you don't have a specific element to anchor your menu to. This is the case for context menus, which open on right-click interactions which may or may not have an anchor (e.g blank canvas). So far this seems like the only case for opening a menu w/o having an anchor, therefore the component was directly namedContextMenu. But it's usage is rather generic so it can serve also other purposes