diff --git a/resources/js/tests/components/WidthSelector.test.js b/resources/js/tests/components/WidthSelector.test.js
new file mode 100644
index 00000000000..574df529de2
--- /dev/null
+++ b/resources/js/tests/components/WidthSelector.test.js
@@ -0,0 +1,45 @@
+import { mount } from '@vue/test-utils';
+import { expect, test } from 'vitest';
+import WidthSelector from '@/components/fields/WidthSelector.vue';
+
+function states(wrapper) {
+ return wrapper.findAll('[data-state]').map((el) => el.attributes('data-state'));
+}
+
+function label(wrapper) {
+ return wrapper.find('.pointer-events-none').text();
+}
+
+test('it fills the stops up to the selected percentage', () => {
+ const wrapper = mount(WidthSelector, { props: { modelValue: 50 } });
+
+ expect(states(wrapper)).toEqual(['selected', 'selected', 'selected', 'unselected', 'unselected', 'unselected']);
+ expect(label(wrapper)).toBe('50%');
+});
+
+test('it fills the stops for a width the stops do not offer', () => {
+ const wrapper = mount(WidthSelector, { props: { modelValue: 6 } });
+
+ expect(states(wrapper)).toEqual(['selected', 'selected', 'selected', 'unselected', 'unselected', 'unselected']);
+ expect(label(wrapper)).toBe('50%');
+});
+
+test('it fills every stop at full width', () => {
+ const wrapper = mount(WidthSelector, { props: { modelValue: 12 } });
+
+ expect(states(wrapper)).toEqual(Array(6).fill('selected'));
+ expect(label(wrapper)).toBe('100%');
+});
+
+test('it marks the last filled stop unless it is full width', () => {
+ expect(mount(WidthSelector, { props: { modelValue: 6 } }).findAll('[data-last="true"]')).toHaveLength(1);
+ expect(mount(WidthSelector, { props: { modelValue: 12 } }).findAll('[data-last="true"]')).toHaveLength(0);
+});
+
+test('it emits the raw stop value when clicked', async () => {
+ const wrapper = mount(WidthSelector, { props: { modelValue: 6 } });
+
+ await wrapper.findAll('[data-state]')[2].trigger('click');
+
+ expect(wrapper.emitted('update:model-value')).toEqual([[50]]);
+});
diff --git a/resources/js/tests/width.test.js b/resources/js/tests/width.test.js
new file mode 100644
index 00000000000..bf26060dbb2
--- /dev/null
+++ b/resources/js/tests/width.test.js
@@ -0,0 +1,72 @@
+import { describe, it, expect } from 'vitest';
+import { widthToColumnSpan, widthToPercentage } from '../util/width.js';
+
+describe('widthToColumnSpan', () => {
+ it('passes spans through', () => {
+ for (let span = 1; span <= 12; span++) {
+ expect(widthToColumnSpan(span)).toBe(span);
+ }
+ });
+
+ it('resolves the percentages that shipped before spans', () => {
+ expect(widthToColumnSpan(25)).toBe(3);
+ expect(widthToColumnSpan(33)).toBe(4);
+ expect(widthToColumnSpan(50)).toBe(6);
+ expect(widthToColumnSpan(66)).toBe(8);
+ expect(widthToColumnSpan(75)).toBe(9);
+ expect(widthToColumnSpan(100)).toBe(12);
+ });
+
+ it('does not resolve the widgets-only size keywords', () => {
+ expect(widthToColumnSpan('sm')).toBe(12);
+ expect(widthToColumnSpan('md')).toBe(12);
+ expect(widthToColumnSpan('lg')).toBe(12);
+ expect(widthToColumnSpan('full')).toBe(12);
+ });
+
+ it('treats numbers above twelve as percentages', () => {
+ expect(widthToColumnSpan(13)).toBe(2);
+ expect(widthToColumnSpan(40)).toBe(5);
+ expect(widthToColumnSpan(90)).toBe(11);
+ });
+
+ it('clamps percentages beyond a full row', () => {
+ expect(widthToColumnSpan(200)).toBe(12);
+ });
+
+ it('handles numeric strings', () => {
+ expect(widthToColumnSpan('50')).toBe(6);
+ expect(widthToColumnSpan('6')).toBe(6);
+ });
+
+ it('falls back to full width for anything it does not recognise', () => {
+ expect(widthToColumnSpan(undefined)).toBe(12);
+ expect(widthToColumnSpan(null)).toBe(12);
+ expect(widthToColumnSpan('')).toBe(12);
+ expect(widthToColumnSpan('huge')).toBe(12);
+ expect(widthToColumnSpan(0)).toBe(12);
+ expect(widthToColumnSpan(-5)).toBe(12);
+ expect(widthToColumnSpan(7.5)).toBe(12);
+ });
+});
+
+describe('widthToPercentage', () => {
+ it('leaves percentages alone', () => {
+ expect(widthToPercentage(25)).toBe(25);
+ expect(widthToPercentage(33)).toBe(33);
+ expect(widthToPercentage(66)).toBe(66);
+ expect(widthToPercentage(100)).toBe(100);
+ });
+
+ it('derives a percentage from a span', () => {
+ expect(widthToPercentage(3)).toBe(25);
+ expect(widthToPercentage(4)).toBe(33);
+ expect(widthToPercentage(6)).toBe(50);
+ expect(widthToPercentage(9)).toBe(75);
+ expect(widthToPercentage(12)).toBe(100);
+ });
+
+ it('reports an unrecognised width as full width', () => {
+ expect(widthToPercentage(undefined)).toBe(100);
+ });
+});
diff --git a/resources/js/util/width.js b/resources/js/util/width.js
new file mode 100644
index 00000000000..8ee1eb6e3e3
--- /dev/null
+++ b/resources/js/util/width.js
@@ -0,0 +1,37 @@
+export const GRID_COLUMNS = 12;
+
+/*
+ * Resolve a field width to a span of the twelve column grid. Mirrors the
+ * .field-w- rules in resources/css/components/widths.css, which is what actually
+ * renders. Widgets don't come through here; their widths are resolved entirely by
+ * the stylesheet, which is also the only place the widgets-only size keywords are
+ * understood.
+ *
+ * Note the discontinuity: 12 is a span, and so full width, while 13 is read as a
+ * percentage and comes out at a sixth. Nothing meaningful lived in 13-24.
+ */
+export function widthToColumnSpan(width) {
+ const number = Number(width);
+
+ if (!Number.isFinite(number)) return GRID_COLUMNS;
+
+ if (Number.isInteger(number) && number >= 1 && number <= GRID_COLUMNS) return number;
+
+ if (number > GRID_COLUMNS) {
+ return Math.min(GRID_COLUMNS, Math.max(1, Math.round((number / 100) * GRID_COLUMNS)));
+ }
+
+ return GRID_COLUMNS;
+}
+
+/*
+ * The width as a percentage, for display. Values that are already percentages
+ * are left alone so that 66 doesn't read back as 67.
+ */
+export function widthToPercentage(width) {
+ const number = Number(width);
+
+ if (Number.isFinite(number) && number > GRID_COLUMNS) return Math.min(100, number);
+
+ return Math.round((widthToColumnSpan(width) / GRID_COLUMNS) * 100);
+}
diff --git a/src/Support/Str.php b/src/Support/Str.php
index 41cada193cf..0904015f2af 100644
--- a/src/Support/Str.php
+++ b/src/Support/Str.php
@@ -258,6 +258,11 @@ public static function modifyMultiple($string, $modifications)
return $string;
}
+ /**
+ * @deprecated Widths are resolved by the control panel stylesheet now. Render
+ * `field-w-{$width}` or `widget-w-{$width}` instead. Will be removed in a
+ * future major version.
+ */
public static function tailwindWidthClass($width)
{
$sizes = [