Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 resources/css/core/utilities.css
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,13 @@
@utility st-text-trim-ex-alphabetic {
text-box: ex alphabetic;
}
@utility st-text-trim-optical-cap {
/* =Jay. Trim to x-height, then nudge the text down so it sits between x-height and cap height, a touch nearer cap height. Lowercase and Capitalised labels both look centred in small, tight boxes like badges */
@supports (text-box: ex alphabetic) {
text-box: ex alphabetic;
padding-top: calc((1cap - 1ex) * 3 / 4);
}
}

@utility placeholder-xs {
/* =Jay. The default of 1em on field inputs feels a bit clumsy/big */
Expand Down
1 change: 1 addition & 0 deletions resources/js/components/assets/Editor/Editor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@
<ui-badge pill icon="memory" :text="asset.size" />
<ui-badge pill icon="fingerprint">
<time
class="st-text-trim-optical-cap"
:datetime="asset.lastModified"
v-tooltip="$date.format(asset.lastModified)"
v-text="asset.lastModifiedRelative" />
Expand Down
8 changes: 5 additions & 3 deletions resources/js/components/collections/Listing.vue
Original file line number Diff line number Diff line change
Expand Up @@ -122,15 +122,15 @@

<ui-panel-footer v-if="collection.available_in_selected_site" class="flex items-center gap-6 gap-y-1.5 text-sm text-gray-600 dark:text-gray-400">
<div class="flex items-center gap-1.5">
<ui-badge :text="String(collection.published_entries_count)" pill class="bg-white! dark:bg-gray-700! [&_span]:st-text-trim-cap" />
<ui-badge :text="String(collection.published_entries_count)" pill class="bg-white! dark:bg-gray-700! [&>span]:st-text-trim-cap [&>span]:pt-0" />
<span>{{ __('Published') }}</span>
</div>
<div class="flex items-center gap-1.5" v-if="collection.scheduled_entries_count > 0">
<ui-badge :text="String(collection.scheduled_entries_count)" pill class="bg-white! dark:bg-gray-700! [&_span]:st-text-trim-cap" />
<ui-badge :text="String(collection.scheduled_entries_count)" pill class="bg-white! dark:bg-gray-700! [&>span]:st-text-trim-cap [&>span]:pt-0" />
<span>{{ __('Scheduled') }}</span>
</div>
<div class="flex items-center gap-1.5" v-if="collection.draft_entries_count > 0">
<ui-badge :text="String(collection.draft_entries_count)" pill class="bg-white! dark:bg-gray-700! [&_span]:st-text-trim-cap" />
<ui-badge :text="String(collection.draft_entries_count)" pill class="bg-white! dark:bg-gray-700! [&>span]:st-text-trim-cap [&>span]:pt-0" />
<span>{{ __('Drafts') }}</span>
</div>
</ui-panel-footer>
Expand Down Expand Up @@ -324,7 +324,9 @@ export default {

<style scoped>
@supports(text-box: cap alphabetic) {
/* =Jay. Counts are only ever digits, so they sit best centred on cap height rather than the usual badge trim */
[data-ui-panel-footer] [data-ui-badge] {
min-height: auto;
padding-block: calc(var(--spacing) * 1.5);
}
}
Expand Down
2 changes: 1 addition & 1 deletion resources/js/components/fieldtypes/ArrayFieldtype.vue
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@
>
<ui-button @click="addValue()" :disabled="atMax" v-if="!isReadOnly && !isSingle && !isKeyed" :text="addButton" size="sm" :class="compactFooterButtonClass" />
<ui-button v-if="isCompact" class="ms-auto" :class="compactFooterButtonClass" size="sm" @click="setCompactOpen(false)">
<span class="st-text-trim-cap">{{ __('Close') }}</span>
<span class="st-text-trim-optical-cap">{{ __('Close') }}</span>
<span class="ms-1.5 inline-flex h-4 min-w-4 items-center justify-center rounded bg-gray-200/50 px-1 font-semibold uppercase text-[0.625rem] text-gray-600 dark:bg-gray-900 dark:text-gray-400/85">
Esc
</span>
Expand Down
6 changes: 3 additions & 3 deletions resources/js/components/fieldtypes/bard/Set.vue
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,10 @@
<button type="button" class="show-focus-within_target flex flex-1 min-w-0 cursor-pointer items-center gap-4 overflow-x-auto p-2 pe-4 focus:outline-none st-mask-horizontal-overflow" @click="toggleCollapsedState">
<Badge size="lg" :pill="true" color="white" class="px-3">
<span v-if="isSetGroupVisible" class="flex items-center gap-2">
{{ __(setGroup.display) }}
<Icon name="chevron-right" class="relative top-px size-3" />
<span class="st-text-trim-optical-cap">{{ __(setGroup.display) }}</span>
<Icon name="chevron-right" class="size-3" />
</span>
{{ __(config.display) || config.handle }}
<span class="st-text-trim-optical-cap">{{ __(config.display) || config.handle }}</span>
</Badge>
<Icon
v-if="config.instructions && !collapsed"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
:status="item.status"
class="h-1"
/>
<span v-text="item.title" />
<span class="st-text-trim-optical-cap" v-text="item.title" />
</Badge>
<Dropdown v-if="hasMore && !showingAll">
<template #trigger>
Expand Down
6 changes: 3 additions & 3 deletions resources/js/components/fieldtypes/replicator/Set.vue
Original file line number Diff line number Diff line change
Expand Up @@ -208,10 +208,10 @@ function onSetDropdownClosed() {
<button type="button" class="show-focus-within_target flex flex-1 min-w-0 cursor-pointer items-center gap-4 overflow-x-auto p-2 py-1.75 pe-4 focus:outline-none st-mask-horizontal-overflow" @click="toggleCollapsedState">
<Badge size="lg" pill color="white" class="px-3">
<span v-if="isSetGroupVisible" class="flex items-center gap-2">
{{ __(setGroup.display) }}
<Icon name="chevron-right" class="relative top-px size-3" />
<span class="st-text-trim-optical-cap">{{ __(setGroup.display) }}</span>
<Icon name="chevron-right" class="size-3" />
</span>
{{ __(config.display) || config.handle }}
<span class="st-text-trim-optical-cap">{{ __(config.display) || config.handle }}</span>
</Badge>
<Icon
v-if="config.instructions && !collapsed"
Expand Down
2 changes: 1 addition & 1 deletion resources/js/components/global-header/Search.vue
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { Icon } from '@ui';
hover:bg-black/45 hover:text-white/70
">
<Icon name="magnifying-glass" class="size-5 flex-none text-white/50 group-hover:text-white/70" />
<span class="sr-only leading-none sm:not-sr-only st-text-trim-cap">{{ __('Search') }}</span>
<span class="max-sm:sr-only leading-none st-text-trim-optical-cap">{{ __('Search') }}</span>
<kbd class="ml-auto hidden self-center rounded-md bg-white/5 px-[0.3125rem] py-[0.0625rem] text-[0.625rem]/4 font-medium text-white/60 group-hover:text-white/70 ring-1 ring-white/7.5 [word-spacing:-0.15em] ring-inset md:block">
<kbd class="font-sans">⌘ </kbd><kbd class="font-sans">K</kbd>
</kbd>
Expand Down
22 changes: 13 additions & 9 deletions resources/js/components/ui/Badge.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script setup>
import { computed, useAttrs, useSlots } from 'vue';
import { Comment, Text, computed, useAttrs, useSlots } from 'vue';
import { cva } from 'cva';
import { twMerge } from 'tailwind-merge';
import Icon from './Icon/Icon.vue';
Expand Down Expand Up @@ -35,6 +35,9 @@ const props = defineProps({
const attrs = useAttrs();
const slots = useSlots();
const hasDefaultSlot = !!slots.default;

// Plain text in the slot gets wrapped so it can be trimmed like the `text` prop
const slotIsText = () => slots.default().every((node) => node.type === Text || node.type === Comment);
const tag = computed(() => {
if (props.href) {
return props.target === '_blank' ? 'a' : Link;
Expand All @@ -44,12 +47,12 @@ const tag = computed(() => {

const badgeClasses = computed(() => {
const classes = cva({
base: 'relative inline-flex items-center justify-center gap-1 border dark:border-none dark:pb-0.25 font-normal antialiased whitespace-nowrap no-underline not-prose [button]:cursor-pointer group [&_svg]:opacity-60 [&_svg]:group-hover:opacity-80 dark:[&_svg]:group-hover:opacity-70',
base: 'relative inline-flex items-center justify-center gap-1 border dark:border-none dark:not-supports-[text-box:ex_alphabetic]:pb-0.25 font-normal antialiased whitespace-nowrap no-underline not-prose [button]:cursor-pointer group [&_svg]:opacity-60 [&_svg]:group-hover:opacity-80 dark:[&_svg]:group-hover:opacity-70',
variants: {
size: {
sm: 'text-2xs leading-normal px-1.25 rounded-[0.1875rem] [&_svg]:size-2.5',
default: 'text-xs leading-5.5 px-2.25 rounded-sm [&_svg]:size-3.5 gap-2',
lg: 'font-medium text-sm leading-7 px-2.5 rounded-lg [&_svg]:size-4 gap-2',
sm: 'text-2xs leading-normal min-h-[calc(1.5em+2px)] px-1.25 rounded-[0.1875rem] [&_svg]:size-2.5',
default: 'text-xs leading-5.5 min-h-[calc(1.375rem+2px)] px-2.25 rounded-sm [&_svg]:size-3.5 gap-2',
lg: 'font-medium text-sm leading-7 min-h-[calc(1.75rem+2px)] px-2.5 rounded-lg [&_svg]:size-4 gap-2',
},
color: {
amber: 'bg-amber-50 dark:bg-gray-800 border-amber-400 dark:border-amber-700 text-amber-700 dark:text-amber-300 [a]:hover:bg-amber-100 dark:[a]:hover:bg-gray-700 [button]:hover:bg-amber-100 dark:[button]:hover:bg-gray-700',
Expand Down Expand Up @@ -92,11 +95,12 @@ const restAttrs = computed(() => {

<template>
<component :is="tag" v-bind="restAttrs" :class="badgeClasses" :href="props.href" :target="target" data-ui-badge>
<span v-if="props.prepend" class="font-medium border-e border-inherit ps-0.5 pe-1.5">{{ prepend }}</span>
<span v-if="props.prepend" class="self-stretch flex items-center font-medium border-e border-inherit ps-0.5 pe-1.5"><span class="st-text-trim-optical-cap">{{ prepend }}</span></span>
<Icon v-if="icon" :name="icon" />
<slot v-if="hasDefaultSlot" />
<template v-else><span :class="{ 'st-text-trim-cap': prepend || append }">{{ text }}</span></template>
<span v-if="hasDefaultSlot && slotIsText()" class="st-text-trim-optical-cap"><slot /></span>
<slot v-else-if="hasDefaultSlot" />
<span v-else class="st-text-trim-optical-cap">{{ text }}</span>
<Icon v-if="iconAppend" :name="iconAppend" />
<span v-if="props.append" class="font-medium border-s border-inherit ps-1.5 pe-0.5">{{ append }}</span>
<span v-if="props.append" class="self-stretch flex items-center font-medium border-s border-inherit ps-1.5 pe-0.5"><span class="st-text-trim-optical-cap">{{ append }}</span></span>
</component>
</template>
10 changes: 10 additions & 0 deletions resources/js/stories/docs/Badge.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,15 @@ Use the `pill` prop to round out the badge.
Badges can be used as links by passing an `href` prop. They will use Inertia to navigate unless you add `target="_blank"` to open in a new tab.
<Canvas of={BadgeStories.Link} sourceState={'shown'} />

## Custom content
Text from the `text` prop, or plain text in the default slot, is trimmed so it sits optically centred. If you pass your own elements in the slot, add the `st-text-trim-optical-cap` class to the element holding the text so it lines up with other badges.

```html
<Badge>
<StatusIndicator status="published" />
<span class="st-text-trim-optical-cap">About us</span>
</Badge>
```

## Arguments
<ArgTypes of={BadgeStories} />
38 changes: 38 additions & 0 deletions resources/js/tests/components/Badge.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { mount } from '@vue/test-utils';
import { expect, test } from 'vitest';
import { h } from 'vue';
import Badge from '@/components/ui/Badge.vue';

const mountBadge = (options) => mount(Badge, { global: { stubs: { Icon: true } }, ...options });

test('text from the text prop is wrapped so it can be trimmed', () => {
const wrapper = mountBadge({ props: { text: 'Published' } });

expect(wrapper.find('span.st-text-trim-optical-cap').text()).toBe('Published');
});

test('plain text in the slot is wrapped so it can be trimmed', () => {
const wrapper = mountBadge({ slots: { default: 'Draft' } });

expect(wrapper.find('span.st-text-trim-optical-cap').text()).toBe('Draft');
});

test('elements in the slot are rendered as they are', () => {
const wrapper = mountBadge({ slots: { default: () => h('time', 'Yesterday') } });

expect(wrapper.find('.st-text-trim-optical-cap').exists()).toBe(false);
expect(wrapper.find('[data-ui-badge] > time').text()).toBe('Yesterday');
});

test('a mix of text and elements in the slot is rendered as it is', () => {
const wrapper = mountBadge({ slots: { default: () => ['Edited ', h('time', 'yesterday')] } });

expect(wrapper.find('.st-text-trim-optical-cap').exists()).toBe(false);
expect(wrapper.find('[data-ui-badge]').text()).toBe('Edited yesterday');
});

test('prepended and appended text is wrapped so it can be trimmed', () => {
const wrapper = mountBadge({ props: { prepend: 'Records', text: '1,204', append: 'cached' } });

expect(wrapper.findAll('.st-text-trim-optical-cap').map((el) => el.text())).toEqual(['Records', '1,204', 'cached']);
});
Loading