diff --git a/src/assets/styles/_utilities.css b/src/assets/styles/_utilities.css index 0810ab177e..effa931677 100644 --- a/src/assets/styles/_utilities.css +++ b/src/assets/styles/_utilities.css @@ -1,3 +1,4 @@ +@import "./utilities/animation.css"; @import "./utilities/aria.css"; @import "./utilities/prose.css"; @import "./utilities/typography.css"; diff --git a/src/assets/styles/utilities/animation.css b/src/assets/styles/utilities/animation.css new file mode 100644 index 0000000000..7129a39b23 --- /dev/null +++ b/src/assets/styles/utilities/animation.css @@ -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; + } +} diff --git a/src/index.ts b/src/index.ts index a79e949dfc..95c5cf49e5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -71,5 +71,9 @@ export { isEmptySlot, isMobile, type Theme, - type ITheme + type ITheme, + useDisabled, + useReadonly, + setDisabled, + setReadonly } from "./utils"; diff --git a/src/ui/autocomplete/Autocomplete.ce.vue b/src/ui/autocomplete/Autocomplete.ce.vue index 03510d6c2c..32e507ee53 100644 --- a/src/ui/autocomplete/Autocomplete.ce.vue +++ b/src/ui/autocomplete/Autocomplete.ce.vue @@ -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" @@ -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, @@ -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 => diff --git a/src/ui/breadcrumb/Breadcrumb.ce.vue b/src/ui/breadcrumb/Breadcrumb.ce.vue index 9d8183d4f5..94fe5237b6 100644 --- a/src/ui/breadcrumb/Breadcrumb.ce.vue +++ b/src/ui/breadcrumb/Breadcrumb.ce.vue @@ -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" /> diff --git a/src/ui/breadcrumb/types.ts b/src/ui/breadcrumb/types.ts index 894a8fff8e..6336bb1423 100644 --- a/src/ui/breadcrumb/types.ts +++ b/src/ui/breadcrumb/types.ts @@ -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"; diff --git a/src/ui/button-group/ButtonGroup.ce.vue b/src/ui/button-group/ButtonGroup.ce.vue index e70de30e82..e0d4c622a3 100644 --- a/src/ui/button-group/ButtonGroup.ce.vue +++ b/src/ui/button-group/ButtonGroup.ce.vue @@ -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(), { @@ -54,9 +54,11 @@ const props = withDefaults(defineProps(), { class: "" }); +const disabled = useDisabled(() => props.disabled); + const meta = computed(() => ({ variant: "control", - isDisabled: props.disabled, + isDisabled: disabled.value, hasRing: true })); diff --git a/src/ui/button/Button.ce.vue b/src/ui/button/Button.ce.vue index 414ad6af15..fe0f053f38 100644 --- a/src/ui/button/Button.ce.vue +++ b/src/ui/button/Button.ce.vue @@ -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"; @@ -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; }); @@ -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 ?? {}); diff --git a/src/ui/checkbox-cards/CheckboxCards.ce.vue b/src/ui/checkbox-cards/CheckboxCards.ce.vue index 6a980ae800..e0ef9c2622 100644 --- a/src/ui/checkbox-cards/CheckboxCards.ce.vue +++ b/src/ui/checkbox-cards/CheckboxCards.ce.vue @@ -2,7 +2,7 @@ props.disabled); + const meta = computed(() => ({ noInput: props.noInput, cursor: props.cursor, diff --git a/src/ui/checkbox-group/CheckboxGroup.vue b/src/ui/checkbox-group/CheckboxGroup.vue index 140b318471..bf8649ab2f 100644 --- a/src/ui/checkbox-group/CheckboxGroup.vue +++ b/src/ui/checkbox-group/CheckboxGroup.vue @@ -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(); +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); diff --git a/src/ui/checkbox/Checkbox.ce.vue b/src/ui/checkbox/Checkbox.ce.vue index d07ddc4eed..d23963cbcd 100644 --- a/src/ui/checkbox/Checkbox.ce.vue +++ b/src/ui/checkbox/Checkbox.ce.vue @@ -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"; // ----------------------------------------------------------------------------- @@ -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, { diff --git a/src/ui/combobox/Combobox.ce.vue b/src/ui/combobox/Combobox.ce.vue index aacb357a6c..8d176a5c1c 100644 --- a/src/ui/combobox/Combobox.ce.vue +++ b/src/ui/combobox/Combobox.ce.vue @@ -1,7 +1,7 @@