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:
parent
4ea287c17f
commit
e09d0a20fa
|
@ -1,11 +1,13 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref, useSlots } from 'vue'
|
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 ColorProps, type DefaultProps, type VariantProps, color, isNoColors } from '~/composables/color';
|
||||||
import { type WidthProps, width } from '~/composables/width'
|
import { type WidthProps, width } from '~/composables/width'
|
||||||
import { type AlignmentProps, align } from '~/composables/alignment'
|
import { type AlignmentProps, align } from '~/composables/alignment'
|
||||||
|
|
||||||
|
import { fromProps, notUndefined } from '~/ui/composables/useModal.ts'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
thickWhenActive?: true
|
thickWhenActive?: true
|
||||||
|
|
||||||
|
@ -26,11 +28,22 @@ const isExternalLink = computed(() =>
|
||||||
typeof props.to === 'string' && (props.to.startsWith('http') || props.to.startsWith('./'))
|
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 [fontWeight, activeFontWeight] = 'solid' in props || props.thickWhenActive ? [600, 900] : [400, 400]
|
||||||
|
|
||||||
const slots = useSlots()
|
const isIconOnly = computed(() =>
|
||||||
|
!!props.icon && (
|
||||||
const isIconOnly = computed(() => !!props.icon && (!slots.default || 'square' in props && props.square || 'squareSmall' in props && props.squareSmall ))
|
!useSlots().default
|
||||||
|
|| 'square' in props && props.square
|
||||||
|
|| 'squareSmall' in props && props.squareSmall
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
const button = ref()
|
const button = ref()
|
||||||
|
|
||||||
|
@ -40,7 +53,7 @@ onMounted(() => {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<component :is="isExternalLink ? 'a' : 'RouterLink'"
|
<component :is="isExternalLink ? 'a' : RouterLink"
|
||||||
v-bind="
|
v-bind="
|
||||||
color(props, ['interactive'])(
|
color(props, ['interactive'])(
|
||||||
width(props,
|
width(props,
|
||||||
|
@ -56,6 +69,7 @@ onMounted(() => {
|
||||||
isIconOnly && $style['is-icon-only'],
|
isIconOnly && $style['is-icon-only'],
|
||||||
(isNoColors(props) || props.forceUnderline) && $style['force-underline'],
|
(isNoColors(props) || props.forceUnderline) && $style['force-underline'],
|
||||||
isNoColors(props) && $style['no-spacing'],
|
isNoColors(props) && $style['no-spacing'],
|
||||||
|
isNoMatchingQueryFlags && 'router-link-no-matching-query-flag'
|
||||||
]"
|
]"
|
||||||
:href="isExternalLink ? to.toString() : undefined"
|
:href="isExternalLink ? to.toString() : undefined"
|
||||||
:to="isExternalLink ? undefined : to"
|
:to="isExternalLink ? undefined : to"
|
||||||
|
|
|
@ -566,7 +566,7 @@
|
||||||
background-color: var(--pressed-background-color, var(--active-background-color));
|
background-color: var(--pressed-background-color, var(--active-background-color));
|
||||||
border-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);
|
color:var(--active-color);
|
||||||
background-color:var(--active-background-color);
|
background-color:var(--active-background-color);
|
||||||
border-color: var(--active-background-color);
|
border-color: var(--active-background-color);
|
||||||
|
@ -607,7 +607,7 @@
|
||||||
/* &.active {
|
/* &.active {
|
||||||
border-color: var(--link-active-border-color);
|
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));
|
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));
|
background-color: var(--pressed-background-color, var(--active-background-color));
|
||||||
border-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);
|
border-color: var(--active-background-color);
|
||||||
&.router-link-exact-active {
|
&.router-link-exact-active {
|
||||||
border-color: var(--exact-active-border-color);
|
border-color: var(--exact-active-border-color);
|
||||||
|
|
|
@ -1,6 +1,11 @@
|
||||||
import { computed } from "vue";
|
import { computed } from "vue";
|
||||||
import { useRouter, type RouteLocationRaw, type LocationQuery } from "vue-router";
|
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)
|
* 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> (
|
export const useModal = <T> (
|
||||||
flag: string,
|
flag: string,
|
||||||
assignment: { on: (value : T | null) => string | null, isOn: (value: LocationQuery[string]) => boolean } =
|
assignment: Assignment<T> = exactlyNull
|
||||||
{ on: (_) => null, isOn: (value) => value === null }
|
|
||||||
) => {
|
) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const query = computed(() =>
|
const query = computed(() =>
|
||||||
|
@ -113,3 +117,9 @@ export const useModal = <T> (
|
||||||
|
|
||||||
return { value, isOpen, to, asAttribute, toggle };
|
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) )
|
||||||
|
: []
|
||||||
|
|
|
@ -1,9 +1,14 @@
|
||||||
<script setup lang="ts">
|
<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 Link from '~/components/ui/Link.vue'
|
||||||
import Button from '~/components/ui/Button.vue'
|
import Button from '~/components/ui/Button.vue'
|
||||||
import Layout from '~/components/ui/Layout.vue'
|
import Layout from '~/components/ui/Layout.vue'
|
||||||
import Card from '~/components/ui/Card.vue'
|
import Card from '~/components/ui/Card.vue'
|
||||||
import Alert from '~/components/ui/Alert.vue'
|
import Alert from '~/components/ui/Alert.vue'
|
||||||
|
|
||||||
|
const { to, isOpen } = useModal('flag')
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
```ts
|
```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`.
|
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
|
## Colors and Variants
|
||||||
|
|
||||||
See [Using color](/using-color)
|
See [Using color](/using-color)
|
||||||
|
|
Loading…
Reference in New Issue