Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
fe4e31f
feat(ui): install stepper primitive from radix-vue [FE-2977]
dominicpedro Aug 5, 2026
f1abbc3
feat(ui): install stepper primitive from radix-vue [FE-2977]
dominicpedro Aug 5, 2026
2aacca7
feat(ui): install context-menu primitive from radix-vue [FE-2977]
dominicpedro Aug 5, 2026
fce0fbe
feat(ui): install context-menu primitive from radix-vue [FE-2977]
dominicpedro Aug 5, 2026
51a3373
test(ui): mount + barrel-resolution proof for stepper and context-men…
dominicpedro Aug 5, 2026
331eb8d
test(ui): mount + barrel-resolution proof for stepper and context-men…
dominicpedro Aug 5, 2026
38841c2
fix(ui): nest StepperSeparator inside StepperItem [FE-2977]
dominicpedro Aug 5, 2026
f8a15fb
fix(ui): nest StepperSeparator inside StepperItem [FE-2977]
dominicpedro Aug 5, 2026
799ed8c
test(ui): prove context-menu convenience wrapper's reason for existin…
dominicpedro Aug 5, 2026
6654178
test(ui): prove context-menu convenience wrapper's reason for existin…
dominicpedro Aug 5, 2026
7843de5
docs(ui): list ContextMenu and Stepper in the primitives table
dominicpedro Aug 5, 2026
76f456e
docs(ui): list ContextMenu and Stepper in the primitives table
dominicpedro Aug 5, 2026
94ae570
refactor(FE-2977): use defineModel + repo Icon in stepper/context-menu
dominicpedro Aug 5, 2026
74fdde2
refactor(FE-2977): use defineModel + repo Icon in stepper/context-menu
dominicpedro Aug 5, 2026
fa7532e
fix(FE-2977): style stepper + context-menu with Upmind tokens (match …
dominicpedro Aug 5, 2026
a8030c3
fix(FE-2977): style stepper + context-menu with Upmind tokens (match …
dominicpedro Aug 5, 2026
e1b18cd
fix(FE-2977): install shadcn stepper + context-menu, layer Upmind tokens
dominicpedro Aug 5, 2026
4365ae1
fix(FE-2977): install shadcn stepper + context-menu, layer Upmind tokens
dominicpedro Aug 5, 2026
2976ca7
fix(FE-2977): match shadcn stepper layout (absolute connectors, both …
dominicpedro Aug 5, 2026
3795789
fix(FE-2977): match shadcn stepper layout (absolute connectors, both …
dominicpedro Aug 5, 2026
d9d7ea0
refactor(FE-2977): clean up imports and improve type usage in stepper…
dominicpedro Aug 5, 2026
492c05f
refactor(FE-2977): clean up imports and improve type usage in stepper…
dominicpedro Aug 5, 2026
d26a836
feat(ui): stepper flex-line connector + config-driven styling
dominicpedro Aug 5, 2026
f10b42f
feat(ui): stepper flex-line connector + config-driven styling
dominicpedro Aug 5, 2026
f4c05e9
style(ui): sort stepper.config tailwind classes (lint-staged)
dominicpedro Aug 5, 2026
007e7a0
style(ui): sort stepper.config tailwind classes (lint-staged)
dominicpedro Aug 5, 2026
54ca9c3
feat(ui): context-menu mirrors dropdown-menu styling
dominicpedro Aug 5, 2026
be6b682
feat(ui): context-menu mirrors dropdown-menu styling
dominicpedro Aug 5, 2026
07b3734
feat(FE-2977): install shadcn table primitives (Task 40, S-D19)
dominicpedro Aug 6, 2026
17002ca
feat(FE-2977): install shadcn table primitives (Task 40, S-D19)
dominicpedro Aug 6, 2026
352b3a5
feat: add Sheet and ToggleGroup components with associated styles and…
dominicpedro Aug 11, 2026
e0c1bb8
feat: add Sheet and ToggleGroup components with associated styles and…
dominicpedro Aug 11, 2026
6e47371
feat: add highlight and invalid ring utilities with associated styles
dominicpedro Aug 13, 2026
4dfe4e4
feat: add highlight and invalid ring utilities with associated styles
dominicpedro Aug 13, 2026
6640ccf
Merge branch 'feature/FE-2977-table' of github.com:upmind/upmind-ui i…
dominicpedro Aug 19, 2026
b35a938
Merge branch 'feature/FE-2977-stepper-context-menu' of github.com:upm…
dominicpedro Aug 19, 2026
09d45dc
Merge branch 'feature/FE-2977-stepper-context-menu' into feature/FE-2…
dominicpedro Aug 19, 2026
32544b2
Merge remote-tracking branch 'origin/main' into feature/FE-2977-table
dominicpedro Aug 19, 2026
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
7 changes: 7 additions & 0 deletions src/assets/styles/ring.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,12 @@ export const groupRingClasses = `${ringClasses} ${groupFocusRing}`;
export const invalidRingClasses =
"aria-invalid:outline-[var(--color-danger-ring)]!";

// The always-on counterpart: a surface in a deliberate mode, not one reporting a
// fault, so primary rather than danger and unconditional rather than state-gated.
// It carries its own width and offset because nothing has to be reserved — the
// outline appears and disappears without moving a pixel of the surface it rings.
export const highlightRingClasses =
"outline outline-2 outline-offset-4 outline-[var(--color-primary-default)]";

// Persistent ring (always shown, not just on focus)
export const persistentRing = "ring-2 ring-control-ring";
7 changes: 6 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,21 +49,26 @@ export * from "./ui/select";
export * from "./ui/select-cards";
export * from "./ui/search";
export * from "./ui/separator";
export * from "./ui/sheet";
export * from "./ui/skeleton";
export * from "./ui/sonner";
export * from "./ui/spinner";
export * from "./ui/stepper";
export * from "./ui/switch";
export * from "./ui/tabs";
export * from "./ui/table";
export * from "./ui/tabs";
export * from "./ui/textarea";
export * from "./ui/toggle";
export * from "./ui/toggle-group";
export * from "./ui/tooltip";

export { default as plugins } from "./plugins";

export {
useStyles,
cn,
useHighlightRing,
useInvalidRing,
useTestAttrs,
useThemes,
useThemeIcons,
Expand Down
12 changes: 9 additions & 3 deletions src/ui/button-group/ButtonGroup.ce.vue
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,14 @@
<Button
v-if="item.type === ButtonGroup.Button"
v-bind="item.props"
:class="styles.buttonGroup.button"
size="lg"
:class="
cn(
styles.buttonGroup.button,
item.active && styles.buttonGroup.active
)
"
:size="size"
:aria-pressed="item.active"
:disabled="disabled || item.props.disabled"
variant="ghost"
:ring="false"
Expand All @@ -25,7 +31,7 @@
v-bind="item.props"
:class="styles.buttonGroup.button"
:ring="false"
size="lg"
:size="size"
variant="ghost"
:to="to"
@update:modelValue="item.handler?.($event)"
Expand Down
81 changes: 81 additions & 0 deletions src/ui/button-group/__tests__/button-group.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/**
* @module ui/button-group/__tests__/button-group
* @description The composed `ButtonGroup`'s selected-position contract: an item
* declared `active` announces itself pressed, so a group standing in for a
* single-select control says which position it is on rather than only looking
* like it.
*/

import { mount, type VueWrapper } from "@vue/test-utils";
import { describe, it, expect, afterEach, beforeAll, vi } from "vitest";
import { ButtonGroup as ButtonGroupTypes } from "../types";
import { map } from "lodash-es";
import type { ButtonGroupItem } from "../types";

const POSITIONS = ["All", "Yes", "No"];

const items = (activeIndex?: number): ButtonGroupItem[] =>
map(POSITIONS, (label, index) => ({
type: ButtonGroupTypes.Button,
active: index === activeIndex,
props: { label }
}));

let barrel: typeof import("../../../index");

beforeAll(async () => {
HTMLCanvasElement.prototype.getContext = (() => ({
fillStyle: "",
fillRect: () => {}
})) as unknown as typeof HTMLCanvasElement.prototype.getContext;

barrel = await import("../../../index");
});

describe("button-group", () => {
let wrapper: VueWrapper | undefined;

afterEach(() => {
wrapper?.unmount();
wrapper = undefined;
});

const pressedStates = () =>
map(wrapper?.findAll("button") ?? [], node =>
node.attributes("aria-pressed")
);

it("draws one position per declared item, labelled from the item", () => {
wrapper = mount(barrel.ButtonGroup, { props: { items: items(1) } });

expect(map(wrapper.findAll("button"), node => node.text())).toEqual(
POSITIONS
);
});

it("announces the active item as pressed, and every other as not", () => {
wrapper = mount(barrel.ButtonGroup, { props: { items: items(1) } });

expect(pressedStates()).toEqual(["false", "true", "false"]);
});

it("announces no position pressed when no item is active", () => {
wrapper = mount(barrel.ButtonGroup, { props: { items: items() } });

expect(pressedStates()).toEqual(["false", "false", "false"]);
});

it("invokes only the pressed item's own handler", async () => {
const yes = vi.fn();
const no = vi.fn();
const declared = items(0);
declared[1].handler = yes;
declared[2].handler = no;

wrapper = mount(barrel.ButtonGroup, { props: { items: declared } });
await wrapper.findAll("button")[1].trigger("click");

expect(yes).toHaveBeenCalledTimes(1);
expect(no).not.toHaveBeenCalled();
});
});
22 changes: 19 additions & 3 deletions src/ui/button-group/buttonGroup.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,32 @@
import { cva } from "class-variance-authority";
import { focusWithinRing } from "../../assets/styles";

/**
* `Button`'s own interactive selector, verbatim from `button.config`'s
* compound variants. Repeated here so `tailwind-merge` sees the SAME modifier
* and drops the ghost hover background — without it the ghost's light hover
* outranks the pressed fill and the active segment washes out on hover/focus.
*/
const interactive =
"&:hover:not(:disabled),&:focus-within:not(:disabled),&[data-hover=true]:not([data-disabled=true]),&[data-focus=true]:not([data-disabled=true])";

export const rootVariants = cva(
`control-radius divide-border-control-default shadow-control-default bg-control-surface [&:hover,&:focus-within]:shadow-control-hover flex divide-x overflow-hidden ${focusWithinRing}`
);

export const buttonVariants = cva("control-radius px-3 py-1");
export const itemVariants = cva("px-1 py-1 leading-none");

export const buttonVariants = cva("control-radius");

export const activeVariants = cva(
`bg-control-checked text-control-checked-contrast shadow-control-checked [${interactive}]:bg-control-checked-hover`
);

export default {
buttonGroup: {
root: rootVariants,
item: cva("px-1 py-1 leading-none"),
button: buttonVariants
item: itemVariants,
button: buttonVariants,
active: activeVariants
}
};
1 change: 1 addition & 0 deletions src/ui/button-group/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { HTMLAttributes } from "vue";
export type ButtonGroupItem = ButtonGroupButtonItem | ButtonGroupDropdownItem;
export type ButtonGroupProps = {
items: ButtonGroupItem[];
size?: Exclude<ButtonProps["size"], "icon">;
disabled?: boolean;
to?: string;
uiConfig?: { buttonGroup: CxOptions };
Expand Down
7 changes: 4 additions & 3 deletions src/ui/dropdown-menu/DropdownMenuCheckboxItem.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
useForwardPropsEmits
} from "radix-vue";
import { type HTMLAttributes, computed } from "vue";
import { checkboxVariants } from "../checkbox-group/checkboxGroup.config";
import { cn } from "../../utils";

const props = defineProps<
Expand All @@ -29,14 +30,14 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
v-bind="forwarded"
:class="
cn(
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden transition-colors select-none data-disabled:pointer-events-none data-disabled:opacity-50',
'group data-highlighted:bg-button-ghost-hover relative flex cursor-pointer items-center gap-2 rounded-xs px-2 py-1.5 text-sm outline-hidden transition-colors select-none data-disabled:pointer-events-none data-disabled:opacity-50',
props.class
)
"
>
<span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<span :class="checkboxVariants()">
<DropdownMenuItemIndicator>
<Check class="h-4 w-4" />
<Check class="text-control-checked-contrast h-3 w-3" />
</DropdownMenuItemIndicator>
</span>
<slot />
Expand Down
2 changes: 1 addition & 1 deletion src/ui/dropdown-menu/DropdownMenuContent.vue
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ const portalTo = usePortalTarget(() => props.to);
v-bind="forwarded"
:class="
cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-32 rounded-lg p-1',
'bg-control-surface text-display data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-32 rounded-lg p-1',
props.class,
persistentRing
)
Expand Down
6 changes: 4 additions & 2 deletions src/ui/form/FormControl.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
: `${props.formDescriptionId} ${props.formMessageId}`
"
:aria-invalid="!!props.invalid"
class="w-full"
:class="cn('w-full', props.class)"
v-bind="attributesToRemove"
>
<slot />
Expand All @@ -19,8 +19,9 @@
import { useIntersectionObserver } from "@vueuse/core"; // or '@vueuse/integrations' in some setups
import { Slot } from "radix-vue";
import { ref, computed, watch } from "vue";
import { cn } from "../../utils";
import { first, isFunction } from "lodash-es";
import type { ComponentPublicInstance } from "vue";
import type { ComponentPublicInstance, HTMLAttributes } from "vue";
// -----------------------------------------------------------------------------

const props = defineProps<{
Expand All @@ -29,6 +30,7 @@ const props = defineProps<{
formMessageId?: string;
invalid?: boolean;
autoFocus?: boolean;
class?: HTMLAttributes["class"];
}>();
// -----------------------------------------------------------------------------

Expand Down
21 changes: 17 additions & 4 deletions src/ui/form/FormField.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,12 @@
>
<slot name="field">
<!-- label -->
<div class="flex w-full flex-col gap-1">
<FormLabel v-if="meta.hasLabel" :formItemId="id">
<div :class="styles.form.field">
<FormLabel
v-if="meta.hasLabel"
:formItemId="id"
:class="styles.form.label"
>
<slot name="label" :label="label">
<span class="inline-flex w-full items-center gap-x-2">
<slot v-if="props.icon" name="icon">
Expand Down Expand Up @@ -42,7 +46,7 @@
</Tooltip>

<FormNotRequiredIndicator
v-if="!meta.isRequired"
v-if="meta.hasOptionalIndicator"
:formItemId="id"
class="ml-auto"
:text="props.optionalText"
Expand All @@ -54,6 +58,7 @@
<!-- input -->
<FormControl
:key="props.id"
:class="styles.form.control"
:invalid="meta.isInvalid"
:disabled="props.disabled"
:required="props.required"
Expand Down Expand Up @@ -92,6 +97,7 @@
import { ref, computed, useSlots } from "vue";
import { Icon } from "../icon";
import { Tooltip } from "../tooltip";
import config from "./form.config";
import {
FormItem,
FormLabel,
Expand All @@ -101,7 +107,7 @@ import {
FormDescription,
FormMessage
} from ".";
import { cn, useTestAttrs } from "../../utils";
import { useStyles, cn, useTestAttrs } from "../../utils";
import { isEmpty, isNil, some, kebabCase } from "lodash-es";
import type { FormControlProps } from "./types";
// -----------------------------------------------------------------------------
Expand Down Expand Up @@ -145,6 +151,7 @@ const meta = computed(
isRequired: boolean;
isVisible: boolean | null;
isDisabled: boolean;
hasOptionalIndicator: boolean;
hasDescription: boolean;
hasLabel: boolean;
hasFeedback: boolean;
Expand All @@ -164,6 +171,10 @@ const meta = computed(
isRequired: props.required,
isVisible: isNil(props.visible) || props.visible,
isDisabled: props.disabled,
// An EMPTY `optionalText` is the caller saying this field's optionality
// needs no indicator — a form whose every control is optional by nature
// reads as noise with one on each. Undefined still draws the default text.
hasOptionalIndicator: !props.required && props.optionalText !== "",
hasDescription: !isEmpty(props.description),
hasLabel: (!isEmpty(props.label) || some(slots, "label")) && !props.noLabel,
hasFeedback:
Expand All @@ -173,6 +184,8 @@ const meta = computed(
})
);

const styles = useStyles(["form"], meta, config, props.uiConfig ?? {});

const testAttrs = useTestAttrs({
key: "form-item",
value: [kebabCase(props.name), props.id]
Expand Down
3 changes: 3 additions & 0 deletions src/ui/form/form.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ export default {
}
}),
description: cva("text-muted w-full text-sm font-normal"),
field: cva("flex w-full flex-col gap-1"),
label: cva(""),
control: cva(""),
actions: cva("flex w-full flex-wrap gap-2 transition-all duration-200", {
variants: {
disabled: {
Expand Down
11 changes: 7 additions & 4 deletions src/ui/form/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,10 +112,13 @@ export type FormControlProps = InputProps & {
uiConfig?: {
input?: CxOptions;
form?: {
root: CxOptions;
loading: CxOptions;
content: CxOptions;
actions: CxOptions;
root?: CxOptions;
loading?: CxOptions;
content?: CxOptions;
actions?: CxOptions;
field?: CxOptions;
label?: CxOptions;
control?: CxOptions;
};
};
class?: HTMLAttributes["class"];
Expand Down
1 change: 1 addition & 0 deletions src/ui/select/select.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export const variants = {
full: "w-full"
},
size: {
sm: "text-sm",
md: "text-sm",
lg: "text-md"
},
Expand Down
Loading