feat(ui): if a link toggles a query flag, then the "active" colors reflect whether any query is currently set

This commit is contained in:
upsiflu 2025-02-18 13:35:44 +01:00
parent 4ea287c17f
commit e09d0a20fa
4 changed files with 59 additions and 10 deletions

View File

@ -1,11 +1,13 @@
<script setup lang="ts">
import { computed, onMounted, ref, useSlots } from 'vue'
import { type RouterLinkProps } from 'vue-router'
import { type RouterLinkProps, RouterLink, useLink } from 'vue-router'
import { type ColorProps, type DefaultProps, type VariantProps, color, isNoColors } from '~/composables/color';
import { type WidthProps, width } from '~/composables/width'
import { type AlignmentProps, align } from '~/composables/alignment'
import { fromProps, notUndefined } from '~/ui/composables/useModal.ts'
const props = defineProps<{
thickWhenActive?: true
@ -26,11 +28,22 @@ const isExternalLink = computed(() =>
typeof props.to === 'string' && (props.to.startsWith('http') || props.to.startsWith('./'))
)
/**
* Any query matches
*/
const isNoMatchingQueryFlags = computed(() =>
fromProps(props, notUndefined)?.every(({ isOpen }) => !isOpen.value)
)
const [fontWeight, activeFontWeight] = 'solid' in props || props.thickWhenActive ? [600, 900] : [400, 400]
const slots = useSlots()
const isIconOnly = computed(() => !!props.icon && (!slots.default || 'square' in props && props.square || 'squareSmall' in props && props.squareSmall ))
const isIconOnly = computed(() =>
!!props.icon && (
!useSlots().default
|| 'square' in props && props.square
|| 'squareSmall' in props && props.squareSmall
)
)
const button = ref()
@ -40,7 +53,7 @@ onMounted(() => {
</script>
<template>
<component :is="isExternalLink ? 'a' : 'RouterLink'"
<component :is="isExternalLink ? 'a' : RouterLink"
v-bind="
color(props, ['interactive'])(
width(props,
@ -56,6 +69,7 @@ onMounted(() => {
isIconOnly && $style['is-icon-only'],
(isNoColors(props) || props.forceUnderline) && $style['force-underline'],
isNoColors(props) && $style['no-spacing'],
isNoMatchingQueryFlags && 'router-link-no-matching-query-flag'
]"
:href="isExternalLink ? to.toString() : undefined"
:to="isExternalLink ? undefined : to"

View File

@ -566,7 +566,7 @@
background-color: var(--pressed-background-color, var(--active-background-color));
border-color: var(--pressed-background-color, var(--active-background-color));
}
&:is(:active, .active) {
&:is(:active, .active:not(.router-link-no-matching-query-flags)) {
color:var(--active-color);
background-color:var(--active-background-color);
border-color: var(--active-background-color);
@ -607,7 +607,7 @@
/* &.active {
border-color: var(--link-active-border-color);
} */
&.router-link-exact-active {
&.router-link-exact-active:not(.router-link-no-matching-query-flags) {
background-color: var(--exact-active-background-color, var(--active-background-color));
}
}
@ -627,7 +627,7 @@
background-color: var(--pressed-background-color, var(--active-background-color));
border-color: var(--pressed-background-color, var(--active-background-color));
}
&:is(:active, .active) {
&:is(:active, .active:not(.router-link-no-matching-query-flags)) {
border-color: var(--active-background-color);
&.router-link-exact-active {
border-color: var(--exact-active-border-color);

View File

@ -1,6 +1,11 @@
import { computed } from "vue";
import { useRouter, type RouteLocationRaw, type LocationQuery } from "vue-router";
type Assignment<T> = { on: (value : T | null) => string | null, isOn: (value: LocationQuery[string]) => boolean }
export const exactlyNull:Assignment<unknown> = ({ on: (_) => null, isOn: (value) => value === null })
export const notUndefined:Assignment<unknown> = ({ on: (_) => null, isOn: (value) => value !== undefined })
/**
* Bind a modal to a single query parameter in the URL (and vice versa)
*
@ -13,8 +18,7 @@ import { useRouter, type RouteLocationRaw, type LocationQuery } from "vue-router
*/
export const useModal = <T> (
flag: string,
assignment: { on: (value : T | null) => string | null, isOn: (value: LocationQuery[string]) => boolean } =
{ on: (_) => null, isOn: (value) => value === null }
assignment: Assignment<T> = exactlyNull
) => {
const router = useRouter();
const query = computed(() =>
@ -113,3 +117,9 @@ export const useModal = <T> (
return { value, isOpen, to, asAttribute, toggle };
}
/* All possible useModals that produce a given `RouterLink` destination */
export const fromProps = <T>({to} : { to?: RouteLocationRaw }, assignment: Assignment<T> = exactlyNull): ReturnType<typeof useModal>[] =>
to && typeof to !== 'string' && 'query' in to && to.query ?
Object.keys(to.query).map( k => useModal(k, assignment) )
: []

View File

@ -1,9 +1,14 @@
<script setup lang="ts">
import { useModal, fromProps, notUndefined } from '~/ui/composables/useModal.ts'
import Modal from '~/components/ui/Modal.vue'
import Link from '~/components/ui/Link.vue'
import Button from '~/components/ui/Button.vue'
import Layout from '~/components/ui/Layout.vue'
import Card from '~/components/ui/Card.vue'
import Alert from '~/components/ui/Alert.vue'
const { to, isOpen } = useModal('flag')
</script>
```ts
@ -30,6 +35,26 @@ This component will render as [an `<a>` element [MDN]](https://developer.mozil
Instead of a route, you can set the prop `to` to any web address starting with `http`.
## `Active` states
- If any ancestor path matches, the `.router-link-active` class is added
- If the whole path matches, the `.router-link-exact-active` class is added
See the [Vue docs](https://router.vuejs.org/guide/essentials/active-links) for a primer on Path matching.
In addition to the standard Vue `RouterLink` path matching function, we use this algorithm:
- If the destination of the link contains any query parameter _and_ none of these is set (i.e. they are all `undefined`), then the class `.router-link-no-matching-query-flags` is added.
This is particularly useful for modals.
<Link ghost :to="useModal('flag').to">
Open modal
</Link>
<Modal v-model="isOpen" title="Modal">
</Modal>
## Colors and Variants
See [Using color](/using-color)