Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
9e651b8
feat(ui): provide/inject disabled + readonly cascade (FE-2810)
RhodriJonesUpmind Jun 8, 2026
d4b6ca1
fix(Input): visual disabled state via data-disabled attribute
RhodriJonesUpmind Jun 8, 2026
4739431
fix(Form): re-provide resolved disabled to descendants (FE-2810)
RhodriJonesUpmind Jun 8, 2026
d41d939
refactor(controls): isDisabled via CVA variant (FE-2810)
RhodriJonesUpmind Jun 8, 2026
6f4644f
refactor(ui): remove unused provider helpers (FE-2810)
RhodriJonesUpmind Jun 8, 2026
d0f3337
refactor(Form): drop unused provideDisabled re-provide (FE-2810)
RhodriJonesUpmind Jun 9, 2026
ae7c5a6
feat(link): loading state with underline shimmer (FE-2810)
RhodriJonesUpmind Jun 11, 2026
1ae6726
Merge branch 'main' into feature/FE-2810
RhodriJonesUpmind Jun 11, 2026
a7a9fcd
feat(breadcrumb): per-item loading + click handler wiring (FE-2810)
RhodriJonesUpmind Jun 11, 2026
8c0ed5c
fix(interstitial): respect per-action loading over internal processin…
RhodriJonesUpmind Jun 12, 2026
047de0a
refactor(form): resolve readonly via useReadonly for cascade parity w…
RhodriJonesUpmind Jun 15, 2026
f5057c2
refactor(ui): back disabled/readonly cascade with a singleton (FE-2810)
RhodriJonesUpmind Jun 18, 2026
b52ef79
refactor(ui): drop is-prefix on standalone disabled/readonly; add gua…
RhodriJonesUpmind Jul 3, 2026
358db16
Merge branch 'main' into feature/FE-2810
dominicpedro Jul 6, 2026
8997b24
Merge branch 'feature/FE-2810' of github.com:upmind/upmind-ui into fe…
dominicpedro Jul 6, 2026
1899da5
refactor(link): format code for better readability in meta computed p…
dominicpedro Jul 6, 2026
43011a8
fix(combobox): restore selected value to #default slot
RhodriJonesUpmind Jul 6, 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
1 change: 1 addition & 0 deletions src/assets/styles/_utilities.css
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
@import "./utilities/animation.css";
@import "./utilities/aria.css";
@import "./utilities/prose.css";
@import "./utilities/typography.css";
Expand Down
27 changes: 27 additions & 0 deletions src/assets/styles/utilities/animation.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/*
* Loading shimmer: dims the masked element and sweeps a full-strength band
* across it. Apply directly (text + underline) or on a pseudo-element
* (underline bar only). Uses @utility so it composes with variants (after:).
*/

@utility shimmer-mask {
mask-image: linear-gradient(
110deg,
rgb(0 0 0 / 0.35) 40%,
rgb(0 0 0) 50%,
rgb(0 0 0 / 0.35) 60%
);
mask-size: 200% 100%;
animation: shimmer 1.25s ease-in-out infinite alternate;
}

/* One full sweep per iteration (band travels edge to edge) so
`animation-direction: alternate` produces a visible back-and-forth. */
@keyframes shimmer {
0% {
mask-position: 0% 0;
}
100% {
mask-position: 100% 0;
}
}
6 changes: 5 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,5 +71,9 @@ export {
isEmptySlot,
isMobile,
type Theme,
type ITheme
type ITheme,
useDisabled,
useReadonly,
setDisabled,
setReadonly
} from "./utils";
6 changes: 4 additions & 2 deletions src/ui/autocomplete/Autocomplete.ce.vue
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
:uiConfig="props.uiConfig"
:popoverClass="props.popoverClass"
:type="props.type"
:disabled="props.disabled"
:disabled="disabled"
:autoFocus="props.autoFocus"
:model-value="modelValue"
v-model:open="open"
Expand Down Expand Up @@ -111,7 +111,7 @@ import { ref, computed, watch } from "vue";
import Avatar from "../avatar/Avatar.ce.vue";
import Icon from "../icon/Icon.ce.vue";
import config from "./autocomplete.config";
import { useStyles } from "../../utils";
import { useStyles, useDisabled } from "../../utils";
import { cn } from "../../utils";
import {
debounce,
Expand Down Expand Up @@ -159,6 +159,8 @@ const meta = computed(() => ({
const open = ref(false);
const processing = ref(false);

const disabled = useDisabled(() => props.disabled);

const itemValue = computed((): string => props.itemValue || "value");
const itemLabel = computed((): string => props.itemLabel || "label");
const modelValue = computed((): AutocompleteItemProps | undefined =>
Expand Down
2 changes: 2 additions & 0 deletions src/ui/breadcrumb/Breadcrumb.ce.vue
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,10 @@
:size="item.icon ? 'sm' : props.size"
:label="item.label"
:icon="item.icon"
:loading="item.loading"
color="muted"
:uiConfig="{ link: { items: '[&>i]:p-[3px]' } } as any"
:action="item.handler"
/>
</BreadcrumbItem>

Expand Down
1 change: 1 addition & 0 deletions src/ui/breadcrumb/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export type BreadcrumbItem = RouterLinkProps & {
value?: string | number;
handler?: (...args: unknown[]) => unknown;
href?: string;
loading?: boolean;
};

export type BreadcrumbVariant = "visible" | "condensed" | "parent" | "hidden";
Expand Down
6 changes: 4 additions & 2 deletions src/ui/button-group/ButtonGroup.ce.vue
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ import { Button } from "../button";
import { Select } from "../select";
import config from "./buttonGroup.config";
import { ButtonGroup } from "./types";
import { useStyles, cn } from "../../utils";
import { useStyles, cn, useDisabled } from "../../utils";
import type { ButtonGroupProps } from "./types";

const props = withDefaults(defineProps<ButtonGroupProps>(), {
Expand All @@ -54,9 +54,11 @@ const props = withDefaults(defineProps<ButtonGroupProps>(), {
class: ""
});

const disabled = useDisabled(() => props.disabled);

const meta = computed(() => ({
variant: "control",
isDisabled: props.disabled,
isDisabled: disabled.value,
hasRing: true
}));

Expand Down
12 changes: 7 additions & 5 deletions src/ui/button/Button.ce.vue
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ import { Spinner } from "../spinner";
import config from "./button.config";
import Button from "./Button.vue";
import ButtonItems from "./ButtonItems.vue";
import { useStyles, cn } from "../../utils";
import { useStyles, cn, useDisabled } from "../../utils";
import { kebabCase } from "lodash-es";
import type { ButtonProps } from "./types";

Expand All @@ -78,11 +78,13 @@ defineEmits<{
click: [event: Event];
}>();

const disabled = useDisabled(() => props.disabled || props.loading);

const component = computed(() => {
if (props.is) return props.is;
// NB if we are disabled and we are a link, we render a button to prevent navigation
if (props.to && !props.disabled) return RouterLink;
if (props.href && !props.disabled) return "a";
if (props.to && !disabled.value) return RouterLink;
if (props.href && !disabled.value) return "a";
// default
return Button;
});
Expand All @@ -100,10 +102,10 @@ const meta = computed(() => ({
align: props.align,
isIconOnly: props.iconOnly,
isBlock: props.block,
isDisabled: props.disabled || props.loading,
isDisabled: disabled.value,
isLoading: props.loading,
isFocusable: props.focusable,
hasRing: props.ring && !props.disabled && props.focusable
hasRing: props.ring && !disabled.value && props.focusable
}));

const styles = useStyles(["button"], meta, config, props.uiConfig ?? {});
Expand Down
8 changes: 5 additions & 3 deletions src/ui/checkbox-cards/CheckboxCards.ce.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<ToggleGroupRoot
v-model="modelValue"
:required="props.required"
:disabled="props.disabled"
:disabled="disabled"
:class="cn(styles.checkboxCards.root, props.class)"
type="multiple"
data-test-key="checkbox-group"
Expand All @@ -15,7 +15,7 @@
:value="item.value as string"
:name="item.id"
:required="props.required"
:disabled="props.disabled"
:disabled="disabled"
:no-input="props.noInput"
:class="cn(styles.checkboxCards.input, props.itemClass)"
:itemClass="styles.checkboxCards.item"
Expand Down Expand Up @@ -105,7 +105,7 @@ import { Label } from "../label";
import { Link } from "../link";
import CheckboxCardItem from "./CheckboxCardItem.vue";
import config from "./checkboxCards.config";
import { cn, useStyles } from "../../utils";
import { cn, useStyles, useDisabled } from "../../utils";
import { includes, isFunction, isString, isNil } from "lodash-es";
import type { CheckboxCardsItemActionProps, CheckboxCardsProps } from "./types";
// -----------------------------------------------------------------------------
Expand Down Expand Up @@ -133,6 +133,8 @@ const modelValue = useVModel(props, "modelValue", emits, {
defaultValue: props.defaultValue
});

const disabled = useDisabled(() => props.disabled);

const meta = computed(() => ({
noInput: props.noInput,
cursor: props.cursor,
Expand Down
7 changes: 5 additions & 2 deletions src/ui/checkbox-group/CheckboxGroup.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,20 @@ import {
useForwardPropsEmits
} from "radix-vue";
import { computed, type HTMLAttributes } from "vue";
import { cn } from "../../utils";
import { assign } from "lodash-es";
import { cn, useDisabled } from "../../utils";

const props = defineProps<
ListboxRootProps & { class?: HTMLAttributes["class"] }
>();
const emits = defineEmits<ListboxRootEmits>();

const disabled = useDisabled(() => props.disabled);

const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;

return delegated;
return assign(delegated, { disabled: disabled.value });
});

const forwarded = useForwardPropsEmits(delegatedProps, emits);
Expand Down
10 changes: 7 additions & 3 deletions src/ui/checkbox/Checkbox.ce.vue
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ import { useVModel } from "@vueuse/core";
import { computed } from "vue";
import config from "./checkbox.config";
import Checkbox from "./Checkbox.vue";
import { useStyles, cn } from "../../utils";
import { omit } from "lodash-es";
import { useStyles, cn, useDisabled } from "../../utils";
import { assign, omit } from "lodash-es";
import type { CheckboxProps } from "./types";
// -----------------------------------------------------------------------------

Expand All @@ -31,8 +31,12 @@ const emits = defineEmits<{
(e: "update:checked", payload: string | number): void;
}>();

const disabled = useDisabled(() => props.disabled);

const delegatedProps = computed(() =>
omit(props, ["class", "uiConfig", "defaultChecked", "checked"])
assign(omit(props, ["class", "uiConfig", "defaultChecked", "checked"]), {
disabled: disabled.value
})
);

const checked = useVModel(props, "checked", emits, {
Expand Down
6 changes: 4 additions & 2 deletions src/ui/combobox/Combobox.ce.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<template>
<Popover
v-model:open="open"
:disabled="props.disabled"
:disabled="disabled"
:class="styles.combobox.root"
>
<PopoverTrigger as-child :ring="false">
Expand Down Expand Up @@ -146,7 +146,7 @@ import {
import Icon from "../icon/Icon.ce.vue";
import { Popover, PopoverContent, PopoverTrigger } from "../popover";
import config from "./combobox.config";
import { cn, useStyles } from "../../utils";
import { cn, useStyles, useDisabled } from "../../utils";
import { find, get, isEmpty, isEqual, isFunction, has } from "lodash-es";
import type { ComboboxProps, ComboboxItemProps } from "./types";

Expand Down Expand Up @@ -193,6 +193,8 @@ const meta = computed(() => ({
const open = ref(false);
const skipFocus = computed(() => props.tabindex === "-1");

const disabled = useDisabled(() => props.disabled);

const itemValue = computed((): string => props.itemValue || "value");
const itemLabel = computed((): string => props.itemLabel || "label");

Expand Down
7 changes: 5 additions & 2 deletions src/ui/command/Command.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
<script lang="ts" setup>
import { ComboboxRoot, useForwardPropsEmits } from "radix-vue";
import { type HTMLAttributes, computed } from "vue";
import { cn } from "../../utils";
import { assign } from "lodash-es";
import { cn, useDisabled } from "../../utils";
import type { ComboboxRootEmits, ComboboxRootProps } from "radix-vue";

const props = withDefaults(
Expand All @@ -14,10 +15,12 @@ const props = withDefaults(

const emits = defineEmits<ComboboxRootEmits>();

const disabled = useDisabled(() => props.disabled);

const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;

return delegated;
return assign(delegated, { disabled: disabled.value });
});

const forwarded = useForwardPropsEmits(delegatedProps, emits);
Expand Down
14 changes: 12 additions & 2 deletions src/ui/form/Form.ce.vue
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,13 @@ import { Button } from "../button";
import config from "./form.config";
import { upmindUIRenderers } from "./renderers";
import { iterateSchema } from "./renderers/utils";
import { useStyles, isDeepEmpty, useValidation } from "../../utils";
import {
useStyles,
isDeepEmpty,
useValidation,
useDisabled,
useReadonly
} from "../../utils";
import { cn } from "../../utils";
import {
isEmpty,
Expand Down Expand Up @@ -132,6 +138,9 @@ const touched = useVModel(props, "touched", emits, {
});
// ---

const disabled = useDisabled(() => props.disabled || props.processing);
const readonly = useReadonly(() => props.readonly);

const meta = computed(() => {
return {
canTranslate: !isEmpty(props.i18n),
Expand All @@ -141,7 +150,8 @@ const meta = computed(() => {
isDirty: baseModel !== model.value,
isTouched: touched.value,
isValid: isEmpty(errors.value),
isDisabled: props.disabled || props.processing
isDisabled: disabled.value,
isReadonly: readonly.value
};
});

Expand Down
1 change: 1 addition & 0 deletions src/ui/form/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ export type FormMeta = {
isTouched: boolean;
isValid: boolean;
isDisabled: boolean;
isReadonly: boolean;
};

export type FormActionsProps = {
Expand Down
34 changes: 20 additions & 14 deletions src/ui/input-password/InputPassword.ce.vue
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
<Link
:class="styles.inputPassword.action"
:focusable="false"
:disabled="props.disabled || props.readonly"
:disabled="disabled || readonly"
@click.prevent="onGenerate"
>
<Icon icon="magic-wand-02" size="2xs" />
Expand Down Expand Up @@ -44,8 +44,8 @@ import Input from "../input/Input.ce.vue";
import { Link } from "../link";
import Tooltip from "../tooltip/Tooltip.ce.vue";
import config from "./input-password.config";
import { useStyles } from "../../utils";
import { omit } from "lodash-es";
import { useStyles, useDisabled, useReadonly } from "../../utils";
import { assign, omit } from "lodash-es";
import type { InputPasswordProps } from "./types";
// -----------------------------------------------------------------------------
const props = withDefaults(defineProps<InputPasswordProps>(), {
Expand All @@ -70,18 +70,24 @@ const modelValue = useVModel(props, "modelValue", emit, {

const unmask = ref(false);

const disabled = useDisabled(() => props.disabled);
const readonly = useReadonly(() => props.readonly);

const delegatedProps = computed(() =>
omit(props, [
"class",
"uiConfig",
"defaultValue",
"modelValue",
"autocomplete",
"generator",
"generateLabel",
"showLabel",
"hideLabel"
])
assign(
omit(props, [
"class",
"uiConfig",
"defaultValue",
"modelValue",
"autocomplete",
"generator",
"generateLabel",
"showLabel",
"hideLabel"
]),
{ disabled: disabled.value, readonly: readonly.value }
)
);

function onGenerate() {
Expand Down
Loading