Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
f8c49a8
Add floating toolbar shortcuts demo
jaygeorge Feb 20, 2026
ca045f5
Prototype wip
jaygeorge Feb 23, 2026
1243067
Make it a bit DRYer
jaygeorge Feb 23, 2026
902bc45
Make accessible
jaygeorge Feb 23, 2026
d1d0d55
Improve style
jaygeorge Feb 23, 2026
1589888
Switch out the backspace symbol because it looks a bit weak and also …
jaygeorge Feb 23, 2026
44a87e6
Better keyboard shortcuts
jaygeorge Feb 23, 2026
ecae15c
Fix keyboard shortcut conflicts with modals and the command palette
jaygeorge Feb 23, 2026
e977608
Change delete from x to del
jaygeorge Feb 23, 2026
83ae379
Add a decent backspace icon
jaygeorge Feb 23, 2026
f884b7d
Tweak backspace icon stroke
jaygeorge Feb 23, 2026
3ab9871
Add some comments
jaygeorge Feb 23, 2026
64fe7f8
Fix localization of letters
jaygeorge Feb 23, 2026
ca9ab66
Tidy everything
jaygeorge Feb 23, 2026
0265663
Tweak colors including dark mode
jaygeorge Feb 23, 2026
43bffd2
Adjust value based on separate "consistent-red" PR
jaygeorge Feb 23, 2026
2b33827
Make keyboard shortcut UI a touch smaller
jaygeorge Feb 23, 2026
7631343
Make keyboard shortcut UI a touch tighter
jaygeorge Feb 24, 2026
c6a98ae
Add keyboard confirmations
jackmcdade Feb 26, 2026
67217d0
Revert "Add keyboard confirmations" in favour of focusing modal instead
jaygeorge Feb 27, 2026
2ccf29c
Modal - focus on first control when using keyboard
jaygeorge Feb 27, 2026
8c1df71
Convert tabs to spaces while we're here
jaygeorge Feb 27, 2026
a289747
Merge branch '6.x' into floating-toolbar-keyboard-shortcuts
jaygeorge Feb 27, 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
14 changes: 12 additions & 2 deletions resources/js/components/fieldtypes/bard/ToolbarButton.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@
:class="{ active, group: variant === 'floating' }"
:variant="variant === 'floating' ? 'subtle' : 'ghost'"
size="sm"
:aria-label="button.text"
v-tooltip="button.text"
:aria-label="tooltipText"
v-tooltip="tooltipText"
@click="button.command(editor, button.args)"
>
<ui-icon :name="button.svg" v-if="button.svg" class="size-3.5! " :class="{ 'group-hover:text-white text-yellow-300!': active && variant === 'floating' }" />
<span v-if="variant === 'floating' && (button.text || button.shortcutKey)" class="ml-1 text-xs">{{ button.text }}{{ button.shortcutKey ? ` ${button.shortcutKey}` : '' }}</span>
<div class="flex items-center" v-html="button.html" v-if="button.html" />
</Button>
</template>
Expand All @@ -28,5 +29,14 @@ export default {
bard: {},
editor: {},
},
computed: {
tooltipText() {
const label = this.button?.text ?? '';
if (this.button?.shortcutKey) {
return label ? `${label} (${this.button.shortcutKey})` : this.button.shortcutKey;
}
return label;
},
},
};
</script>
2 changes: 1 addition & 1 deletion resources/js/components/ui/Button/Button.vue
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ const buttonClasses = computed(() => {
<Icon v-if="icon" :name="icon" />
<Icon v-if="loading" name="loading" :size />

<div :class="{ 'st-text-trim-start': size !== 'xs' && size !== 'sm' }" class="flex content-center">
<div :class="{ 'st-text-trim-start': size !== 'xs' && size !== 'sm' }" class="flex content-center items-center">
<slot v-if="hasDefaultSlot" />
<template v-else>{{ text }}</template>
</div>
Expand Down
33 changes: 7 additions & 26 deletions resources/js/components/ui/Listing/BulkActions.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { injectListingContext } from '../Listing/Listing.vue';
import { computed, ref, watch } from 'vue';
import { Button, ButtonGroup } from '@ui';
import BulkActions from '@/components/actions/BulkActions.vue';
import BulkActionsFloatingToolbar from './BulkActionsFloatingToolbar.vue';

const { actionUrl, actionContext, selections, refresh, clearSelections } = injectListingContext();
const busy = ref(false);
Expand Down Expand Up @@ -53,31 +54,11 @@ function actionFailed(response) {
@completed="actionCompleted"
v-slot="{ actions }"
>
<Motion
v-if="visible"
layout
data-floating-toolbar
class="pointer-events-none sticky inset-x-0 bottom-1 sm:bottom-6 z-(--z-index-above) flex w-full max-w-[95vw] mx-auto justify-center"
:initial="{ y: 100, opacity: 0 }"
:animate="{ y: 0, opacity: 1 }"
:transition="{ duration: 0.2, ease: 'easeInOut' }"
>
<div class="pointer-events-auto space-y-3 rounded-xl border border-gray-300/60 dark:border-gray-700 p-1 bg-gray-200/55 shadow-[0_1px_16px_-2px_rgba(63,63,71,0.2)] dark:bg-gray-800 dark:shadow-[0_10px_15px_rgba(0,0,0,.5)] dark:inset-shadow-2xs dark:inset-shadow-white/10">
<ButtonGroup>
<Button
class="text-blue-500!"
:text="__n(`Deselect :count item|Deselect all :count items`, selections.length)"
@click="clearSelections"
/>
<Button
v-for="action in actions"
:key="action.handle"
:text="__(action.title)"
:variant="action.dangerous ? 'danger' : 'default'"
@click="action.run"
/>
</ButtonGroup>
</div>
</Motion>
<BulkActionsFloatingToolbar
:actions="actions"
:visible="visible"
:selections="selections"
:clear-selections="clearSelections"
/>
</BulkActions>
</template>
155 changes: 155 additions & 0 deletions resources/js/components/ui/Listing/BulkActionsFloatingToolbar.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
<script setup>
/**
* Bulk Actions Floating Toolbar
*
* Renders the floating toolbar when items are selected in a listing, with keyboard shortcuts
* for each action. Shortcuts are derived from the localized action title (first unused letter).
* Delete uses the backspace icon and is triggered by Delete/Backspace only.
*/
import { Motion } from 'motion-v';
import { computed, onMounted, onUnmounted } from 'vue';
import { Button, ButtonGroup, Icon } from '@ui';

// ——— Keyboard shortcut constants ———
const DESELECT_SHORTCUT_KEY = 'Escape';
const DESELECT_SHORTCUT_LABEL = 'Esc';
const DELETE_SHORTCUT_KEY = 'Delete';

const shortcutKeyClasses =
'ms-1.5 inline-flex h-4 min-w-4 items-center justify-center rounded bg-gray-200/75 px-1 font-semibold uppercase text-[0.625rem] text-gray-600 dark:bg-gray-800 dark:text-gray-400';

const props = defineProps({
actions: { type: Array, default: () => [] },
visible: { type: Boolean, default: false },
selections: { type: Array, default: () => [] },
clearSelections: { type: Function, default: null },
});

const hasSelections = computed(() => (props.selections?.length ?? 0) > 0);

/** True if this action is the built-in delete (by handle or trash icon). */
function isDeleteAction(action) {
return action?.handle?.toLowerCase() === 'delete' || action?.icon === 'trash';
}

/**
* First unused a–z letter from the action's (localized) title.
* e.g. "Unpublish" → u, "Veröffentlichung aufheben" → v.
*/
function findShortcutKey(action, used) {
const title = (action.title || '').toLowerCase();
for (const char of title) {
if (/[a-z]/.test(char) && !used.has(char)) return char;
}

return null;
}

const actionsWithShortcuts = computed(() => {
const used = new Set();
return (props.actions || []).map((action) => {
// Delete always shows the backspace icon and is triggered by Delete/Backspace only; no letter.
if (isDeleteAction(action)) {
return { ...action, shortcutKey: null, shortcutLabel: null };
}
const key = findShortcutKey(action, used);
if (key && key.length === 1) used.add(key);
return { ...action, shortcutKey: key, shortcutLabel: key };
});
});

// ——— Keyboard handler: skip when overlays or form controls have focus ———
function hasOpenOverlay() {
return !!document.querySelector(
'[data-ui-modal-content], .stack-content, [role="dialog"]'
);
}

function isInsideFormControl(event) {
const el = event.target;
if (!el) return false;
const tag = el.tagName;
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || el.isContentEditable;
}

function onKeydown(event) {
if (!props.visible || !hasSelections.value) return;
if (hasOpenOverlay() || isInsideFormControl(event)) return;

if (event.key === DESELECT_SHORTCUT_KEY) {
props.clearSelections?.();
event.preventDefault();
event.stopPropagation();
return;
}
if (event.key === DELETE_SHORTCUT_KEY || event.key === 'Backspace') {
const deleteAction = actionsWithShortcuts.value.find(isDeleteAction);
if (deleteAction?.run) {
deleteAction.run();
event.preventDefault();
}
return;
}

// Single-letter shortcuts: ignore when a modifier is held.
if (event.metaKey || event.ctrlKey || event.altKey) return;
const key = event.key?.length === 1 ? event.key.toLowerCase() : null;
if (!key) return;
const action = actionsWithShortcuts.value.find((a) => a.shortcutKey === key);
if (action?.run) {
action.run();
event.preventDefault();
}
}

// Capture phase so Escape is handled before other listeners (e.g. command palette).
onMounted(() => document.addEventListener('keydown', onKeydown, true));
onUnmounted(() => document.removeEventListener('keydown', onKeydown, true));
</script>

<template>
<Motion
v-if="visible"
layout
data-floating-toolbar
class="pointer-events-none sticky inset-x-0 bottom-1 sm:bottom-6 z-(--z-index-above) flex w-full max-w-[95vw] mx-auto justify-center"
:initial="{ y: 100, opacity: 0 }"
:animate="{ y: 0, opacity: 1 }"
:transition="{ duration: 0.2, ease: 'easeInOut' }"
>
<div class="pointer-events-auto space-y-3 rounded-xl border border-gray-300/60 dark:border-gray-700 p-1 bg-gray-200/55 shadow-[0_1px_16px_-2px_rgba(63,63,71,0.2)] dark:bg-gray-800 dark:shadow-[0_10px_15px_rgba(0,0,0,.5)] dark:inset-shadow-2xs dark:inset-shadow-white/10">
<ButtonGroup>
<Button
class="text-blue-500!"
@click="clearSelections?.()"
>
{{ __n(`Deselect :count item|Deselect all :count items`, selections.length) }}
<span :class="[shortcutKeyClasses, 'text-blue-600! bg-blue-100/80! dark:text-blue-400! dark:bg-blue-950!']">
{{ DESELECT_SHORTCUT_LABEL }}
</span>
</Button>
<Button
v-for="action in actionsWithShortcuts"
:key="action.handle"
:variant="action.dangerous ? 'danger' : 'default'"
@click="action.run"
>
{{ __(action.title) }}
<!-- Delete always shows backspace icon; other actions show their shortcut letter. -->
<span
:class="[
shortcutKeyClasses,
'inline-flex items-center',
isDeleteAction(action) && 'ms-0.25!',
action.dangerous && '[&_svg]:text-red-600! dark:[&_svg]:text-red-500! [&_svg]:size-3.75! [&_svg]:opacity-70 dark:[&_svg]:opacity-80 bg-transparent dark:bg-transparent',
]"
>
<Icon v-if="isDeleteAction(action)" name="backspace" class="size-3" />
<template v-else>{{ action.shortcutLabel }}</template>
</span>
</Button>
</ButtonGroup>
</div>
</Motion>
</template>

63 changes: 41 additions & 22 deletions resources/js/components/ui/Modal/Modal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,17 @@ const slots = useSlots();
const emit = defineEmits(['update:open', 'opened', 'dismissed']);

const props = defineProps({
/** When `true`, the modal's backdrop will be blurred */
/** When `true`, the modal's backdrop will be blurred */
blur: { type: Boolean, default: false },
/** Title displayed at the top of the modal */
/** Title displayed at the top of the modal */
title: { type: String, default: '' },
/** Icon name. [Browse available icons](/?path=/story/components-icon--all-icons) */
/** Icon name. [Browse available icons](/?path=/story/components-icon--all-icons) */
icon: { type: [String, null], default: null },
/** The controlled open state of the modal. */
/** The controlled open state of the modal. */
open: { type: Boolean, default: false },
/** Callback that fires before the modal closes. */
beforeClose: { type: Function, default: () => true },
/** When `true`, clicking outside the modal will dismiss it. */
/** Callback that fires before the modal closes. */
beforeClose: { type: Function, default: () => true },
/** When `true`, clicking outside the modal will dismiss it. */
dismissible: { type: Boolean, default: true },
});

Expand All @@ -52,6 +52,7 @@ const modalClasses = cva({
})({});

const modal = ref(null);
const modalContent = ref(null);
const mounted = ref(false);
const visible = ref(false);
const escBinding = ref(null);
Expand All @@ -69,15 +70,33 @@ function open() {

nextTick(() => {
mounted.value = true;
updateOpen(true);
updateOpen(true);

nextTick(() => {
visible.value = true;
emit('opened');
});
nextTick(() => {
visible.value = true;
emit('opened');
nextTick(() => focusFirstFocusable());
});
});
}

const FOCUSABLE_SELECTOR = [
'button:not([disabled])',
'[href]',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
].join(', ');

function focusFirstFocusable() {
const first = modalContent.value?.querySelector(FOCUSABLE_SELECTOR);
if (first instanceof HTMLElement) {
first.focus();
} else {
modalContent.value?.focus();
}
}

function close() {
visible.value = false;

Expand All @@ -93,31 +112,31 @@ function close() {

function dismiss() {
if (!props.dismissible) return;
if (!runCloseCallback()) return;
if (!runCloseCallback()) return;

emit('dismissed');
close();
}

function updateOpen(value) {
if (isUsingOpenProp.value && props.open !== value) {
if (isUsingOpenProp.value && props.open !== value) {
emit('update:open', value);
}
}

function runCloseCallback() {
const shouldClose = props.beforeClose();
const shouldClose = props.beforeClose();

if (!shouldClose) return false;
if (!shouldClose) return false;

close();
close();

return true;
return true;
}

function cleanup() {
modal.value?.destroy();
escBinding.value?.destroy();
modal.value?.destroy();
escBinding.value?.destroy();
}

watch(
Expand All @@ -136,7 +155,7 @@ onBeforeUnmount(() => {
defineExpose({
open,
close,
runCloseCallback,
runCloseCallback,
});

provide('closeModal', close);
Expand Down Expand Up @@ -166,7 +185,7 @@ provide('closeModal', close);
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div v-if="visible" :class="[modalClasses, attrs.class]" data-ui-modal-content>
<div ref="modalContent" v-if="visible" :class="[modalClasses, attrs.class]" data-ui-modal-content>
<div class="relative space-y-3 rounded-xl overflow-auto max-h-[60vh] border border-gray-400/60 bg-white p-4 shadow-[0_1px_16px_-2px_rgba(63,63,71,0.2)] dark:border-none dark:bg-gray-800 dark:shadow-[0_1px_16px_-2px_rgba(0,0,0,.5)] dark:inset-shadow-2xs dark:inset-shadow-white/10">
<div v-if="!hasModalTitleComponent && (title || icon)" data-ui-modal-title class="flex items-center gap-2">
<Icon :name="icon" v-if="icon" class="size-4" />
Expand Down
2 changes: 2 additions & 0 deletions resources/svg/icons/backspace.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.