Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
b22ab9d
Configure table widths
jaygeorge Aug 14, 2026
d55958b
Tweak max-widths
jaygeorge Aug 14, 2026
79771d6
Adjust stack breakpoint for /sites
jaygeorge Aug 14, 2026
b376454
Prevent all fields from blowing out
jaygeorge Aug 17, 2026
56cd82b
Improve
jaygeorge Aug 17, 2026
323fe1b
Fix Pint formatting in Sites blueprint
jaygeorge Aug 17, 2026
4f200c8
Prototype custom attribute popover
jaygeorge Aug 14, 2026
2ad5342
Simplify
jaygeorge Aug 17, 2026
c0caeb3
Merge branch '6.x' into make-compact-array-fieldtype
jaygeorge Aug 19, 2026
e0848c2
Bring back the compact array field for sites
jaygeorge Aug 19, 2026
cf957e8
Tidy compact attribute
jaygeorge Aug 19, 2026
78b410f
Remove enter escaping the popover, we'll do something different
jaygeorge Aug 19, 2026
3e59d44
Enter should create a new row
jaygeorge Aug 19, 2026
6d96949
Keyboard shortcut
jaygeorge Aug 19, 2026
d845086
Improve readability
jaygeorge Aug 19, 2026
2bc93a5
Improve readability
jaygeorge Aug 19, 2026
b156b1b
Clean buttons so they don't clash with any buttons below
jaygeorge Aug 19, 2026
005872d
Restore comments so it's easier to trace body class meanings
jaygeorge Aug 19, 2026
04a5984
Add a data-ui-confirmation-modal helper for singling out confirmation…
jaygeorge Aug 19, 2026
299f37d
Branch review - Fix compact array add-row, empty-close dirtying the v…
jaygeorge Aug 19, 2026
72bb6ea
Fix the popover appearing above the delete row confirmation modal
jaygeorge Aug 19, 2026
a051fcd
Merge branch '6.x' into make-compact-array-fieldtype
jasonvarga Aug 19, 2026
ec1eadd
Revert "Fix the popover appearing above the delete row confirmation m…
jaygeorge Aug 20, 2026
e05a5a7
Merge branch '6.x' into make-compact-array-fieldtype
jaygeorge Aug 20, 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
1 change: 1 addition & 0 deletions lang/en/fieldtypes.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
'any.config.antlers' => 'Enable Antlers parsing in this field\'s content.',
'any.config.cast_booleans' => 'Options with values of true and false will be saved as booleans.',
'any.config.mode' => 'Choose your preferred UI style.',
'array.config.compact' => 'Show a trigger and edit values in a popover. Useful in tables and other tight layouts.',
'array.config.expand' => 'Whether to save the array in the expanded format. Use this if you intend to have numeric values.',
'array.config.keys' => 'Set the array keys (variables) and their optional labels.',
'array.config.mode' => 'The **dynamic** mode gives the user free control of the data, while **keyed** and **single** modes enforce strict keys.',
Expand Down
293 changes: 199 additions & 94 deletions resources/js/components/fieldtypes/ArrayFieldtype.vue
Original file line number Diff line number Diff line change
@@ -1,99 +1,129 @@
<template>
<div>
<ui-input-group v-if="isSingle">
<ui-input-group-prepend>
<select
class="appearance-none border-0 bg-transparent text-sm shadow-none outline-hidden"
@input="setKey($event.target.value)"
<div :class="{ 'w-full min-w-0': isCompact }">
<component :is="wrapperComponent" v-bind="wrapperBinds">
<template v-if="isCompact" #trigger>
<ui-button
class="w-full min-w-0 shrink justify-between"
icon-append="chevron-down"
:aria-expanded="compactOpen"
aria-haspopup="dialog"
>
<option
v-for="(element, index) in keyedData"
v-text="keys[element.key] || element.key"
:key="element._id"
:value="element.key"
:selected="element.key === selectedKey"
/>
</select>
<ui-icon name="chevron-down" class="size-3 ms-1" />
</ui-input-group-prepend>
<template v-for="(element, index) in keyedData">
<ui-input
v-if="element.key === selectedKey"
v-model="data[index].value"
input-class="border-l-0"
:key="element._id"
:id="fieldId + '__' + element.key"
:readonly="isReadOnly"
:input-attrs="{ dir: contentDirection }"
/>
<span
class="block min-w-0 flex-1 truncate text-start font-normal"
:class="{ 'text-gray-500 dark:text-gray-400': !hasCompactValues }"
>
{{ compactTriggerText }}
</span>
</ui-button>
</template>
</ui-input-group>

<table class="table-contained" v-else-if="isKeyed">
<tbody>
<tr v-if="data" v-for="(element, index) in keyedData" :key="element._id">
<th class="w-1/4">
<label :for="fieldId + '__' + element.key">{{ keys[element.key] || element.key }}</label>
</th>
<td>
<input
type="text"
class="w-full input-text"
:id="fieldId + '__' + element.key"

<div ref="editor" @keydown.enter="addRowOnEnter">
<ui-input-group v-if="isSingle">
<ui-input-group-prepend>
<select
class="appearance-none border-0 bg-transparent text-sm shadow-none outline-hidden"
@input="setKey($event.target.value)"
>
<option
v-for="(element, index) in keyedData"
v-text="keys[element.key] || element.key"
:key="element._id"
:value="element.key"
:selected="element.key === selectedKey"
/>
</select>
<ui-icon name="chevron-down" class="size-3 ms-1" />
</ui-input-group-prepend>
<template v-for="(element, index) in keyedData">
<ui-input
v-if="element.key === selectedKey"
v-model="data[index].value"
input-class="border-l-0"
:key="element._id"
:id="fieldId + '__' + element.key"
:readonly="isReadOnly"
:dir="contentDirection"
:input-attrs="{ dir: contentDirection }"
/>
</td>
</tr>
</tbody>
</table>

<table class="table-contained" v-if="isDynamic && valueCount">
<thead>
<tr>
<th class="grid-drag-handle-header" v-if="!isReadOnly"></th>
<th class="w-1/4">{{ keyHeader }}</th>
<th class="">{{ valueHeader }}</th>
<th class="row-controls" v-if="!isReadOnly"></th>
</tr>
</thead>

<sortable-list
v-model="data"
:vertical="true"
item-class="sortable-row"
handle-class="sortable-handle"
:mirror="false"
>
<tbody>
<tr class="sortable-row" v-for="(element, index) in data" :key="element._id">
<td class="sortable-handle table-drag-handle" v-if="!isReadOnly"></td>
<td>
<ui-input
v-model="element.key"
:readonly="isReadOnly"
:input-attrs="{ dir: contentDirection }"
/>
</td>
<td>
<ui-input
v-model="element.value"
:readonly="isReadOnly"
:input-attrs="{ dir: contentDirection }"
/>
</td>
<td class="row-controls" v-if="!isReadOnly">
<ui-button icon="x" variant="subtle" size="xs" round delete-action @click="deleteOrConfirm(index)" :aria-label="__('Delete Row')" v-tooltip="__('Delete Row')" />
</td>
</tr>
</tbody>
</sortable-list>
</table>

<div class="flex gap-2">
<ui-button @click="addValue" :disabled="atMax" v-if="!isReadOnly && !isSingle && !isKeyed" :text="addButton" size="sm" />
</div>
</template>
</ui-input-group>

<table class="table-contained" :class="{ 'mb-0': isCompact }" v-else-if="isKeyed">
<tbody>
<tr v-if="data" v-for="(element, index) in keyedData" :key="element._id">
<th class="w-1/4">
<label :for="fieldId + '__' + element.key">{{ keys[element.key] || element.key }}</label>
</th>
<td>
<input
type="text"
class="w-full input-text"
:id="fieldId + '__' + element.key"
v-model="data[index].value"
:readonly="isReadOnly"
:dir="contentDirection"
/>
</td>
</tr>
</tbody>
</table>

<table class="table-contained" :class="{ 'mb-0': isCompact }" v-if="isDynamic && valueCount">
<thead>
<tr>
<th class="grid-drag-handle-header" v-if="!isReadOnly"></th>
<th class="w-1/4">{{ keyHeader }}</th>
<th class="">{{ valueHeader }}</th>
<th class="row-controls" v-if="!isReadOnly"></th>
</tr>
</thead>

<sortable-list
v-model="data"
:vertical="true"
item-class="sortable-row"
handle-class="sortable-handle"
:mirror="false"
>
<tbody>
<tr class="sortable-row" v-for="(element, index) in data" :key="element._id">
<td class="sortable-handle table-drag-handle" v-if="!isReadOnly"></td>
<td>
<ui-input
v-model="element.key"
:readonly="isReadOnly"
:input-attrs="{ dir: contentDirection }"
/>
</td>
<td>
<ui-input
v-model="element.value"
:readonly="isReadOnly"
:input-attrs="{ dir: contentDirection }"
/>
</td>
<td class="row-controls" v-if="!isReadOnly">
<ui-button icon="x" variant="subtle" size="xs" round delete-action @click="deleteOrConfirm(index)" :aria-label="__('Delete Row')" v-tooltip="__('Delete Row')" />
</td>
</tr>
</tbody>
</sortable-list>
</table>

<div
v-if="(!isReadOnly && !isSingle && !isKeyed) || isCompact"
class="flex w-full items-center gap-2"
:class="{ 'mt-2': isCompact && valueCount }"
>
<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="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>
</ui-button>
</div>
</div>
</component>

<confirmation-modal
:open="deleting !== false"
Expand All @@ -110,7 +140,7 @@
<script>
import Fieldtype from './Fieldtype.vue';
import { SortableList, SortableHelpers } from '../sortable/Sortable';
import { Button } from '@/components/ui';
import { Button, Popover } from '@/components/ui';
import { useContentDirection } from '@/composables/content-direction';

export default {
Expand All @@ -119,6 +149,7 @@ export default {
components: {
SortableList,
Button,
Popover,
},

setup() {
Expand All @@ -135,6 +166,7 @@ export default {
data: this.objectToSortable(this.value || []),
selectedKey,
deleting: false,
compactOpen: false,
};
},

Expand Down Expand Up @@ -169,6 +201,28 @@ export default {
return this.config.mode === 'single';
},

isCompact() {
return this.config.compact === true;
},

wrapperComponent() {
return this.isCompact ? Popover : 'div';
},

wrapperBinds() {
if (!this.isCompact) return {};

return {
align: 'end',
side: 'bottom',
class: 'w-[32rem]',
dismissible: this.deleting === false,
excludeZManipulation: true,
open: this.compactOpen,
'onUpdate:open': this.setCompactOpen,
};
},

keyedData() {
return this.data.filter((element) => this.keys.hasOwnProperty(element.key));
},
Expand Down Expand Up @@ -197,6 +251,25 @@ export default {
return __(this.config.value_header || 'Value');
},

compactFooterButtonClass() {
if (!this.isCompact) return;

return 'from-white to-white hover:from-white hover:to-gray-50';
},

hasCompactValues() {
return this.data.some((element) => element.key || element.value);
},

compactTriggerText() {
if (!this.hasCompactValues) return this.addButton;

return this.data
.filter((element) => element.key || element.value)
.map((element) => element.key || this.valueHeader)
.join(', ');
},

replicatorPreview() {
if (!this.showFieldPreviews) return;
if (!this.value) return '';
Expand All @@ -209,10 +282,42 @@ export default {
},

methods: {
addValue() {
this.data.push(this.newSortableValue());
setCompactOpen(open) {
if (!open) {
this.data = this.data.filter((element) => element.key || element.value);
}

this.compactOpen = open;

if (open && !this.valueCount && !this.isReadOnly) {
this.addValue();
}
},

addRowOnEnter(event) {
if (!this.isDynamic || this.isReadOnly || this.atMax) return;
if (event.target.tagName !== 'INPUT') return;

event.preventDefault();

const rows = [...(this.$refs.editor?.querySelectorAll('tr.sortable-row') ?? [])];
const current = event.target.closest('tr.sortable-row');
const index = rows.indexOf(current);

this.addValue(index === -1 ? this.data.length : index + 1);
},

addValue(index = this.data.length) {
if (typeof index !== 'number' || Number.isNaN(index)) {
index = this.data.length;
}

this.data.splice(index, 0, this.newSortableValue());
this.$nextTick(() => {
this.$el.querySelector('tr:last-child input').focus();
this.$refs.editor
?.querySelectorAll('tr.sortable-row')[index]
?.querySelector('input')
?.focus();
});
},

Expand Down
1 change: 1 addition & 0 deletions resources/js/components/ui/Modal/ConfirmationModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ const shouldCloseOnSubmit = computed(() => {
<template>
<Modal
ref="modal"
data-ui-confirmation-modal
:title="__(title)"
:open="open"
:blur="blur"
Expand Down
7 changes: 6 additions & 1 deletion resources/js/components/ui/Modal/Modal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ const props = defineProps({
dismissible: { type: Boolean, default: true },
});

const restAttrs = computed(() => {
const { class: _, ...rest } = attrs;
return rest;
});

const overlayClasses = cva({
base: 'fixed inset-0 z-(--z-index-portal) bg-gray-800/20 dark:bg-gray-950/60',
variants: {
Expand Down Expand Up @@ -185,7 +190,7 @@ provide('closeModal', close);
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div ref="modalContent" v-if="visible" :class="[modalClasses, attrs.class]" data-ui-modal-content>
<div ref="modalContent" v-if="visible" v-bind="restAttrs" :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
Loading
Loading