From 78b5984d49d8f17e8737751d5704e213726e196d Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Thu, 23 Jul 2026 15:31:55 +0100 Subject: [PATCH 1/5] extract `StarRating` into a ui component --- packages/cms/src/ui.js | 1 + resources/css/cp.css | 1 - resources/js/bootstrap/cms/ui.js | 1 + .../fieldtypes/StarRatingFieldtype.vue | 68 ++---- .../components/ui/StarRating/StarRating.vue} | 106 ++++++++- resources/js/components/ui/index.js | 1 + resources/js/stories/StarRating.stories.ts | 220 ++++++++++++++++++ resources/js/stories/docs/StarRating.mdx | 27 +++ resources/js/tests/Package.test.js | 1 + 9 files changed, 370 insertions(+), 56 deletions(-) rename resources/{css/components/fieldtypes/star-rating.css => js/components/ui/StarRating/StarRating.vue} (60%) create mode 100644 resources/js/stories/StarRating.stories.ts create mode 100644 resources/js/stories/docs/StarRating.mdx diff --git a/packages/cms/src/ui.js b/packages/cms/src/ui.js index 3f5150061c9..1e5f8bdc30a 100644 --- a/packages/cms/src/ui.js +++ b/packages/cms/src/ui.js @@ -98,6 +98,7 @@ export const { SplitterGroup, SplitterPanel, SplitterResizeHandle, + StarRating, StatusIndicator, Subheading, Switch, diff --git a/resources/css/cp.css b/resources/css/cp.css index d3aef6130c6..9f8c9786f8f 100644 --- a/resources/css/cp.css +++ b/resources/css/cp.css @@ -38,6 +38,5 @@ @import './components/fieldtypes/partial.css'; @import './components/fieldtypes/relationship.css'; @import './components/fieldtypes/section.css'; -@import './components/fieldtypes/star-rating.css'; @import './components/fieldtypes/table.css'; @import './components/fieldtypes/width.css'; diff --git a/resources/js/bootstrap/cms/ui.js b/resources/js/bootstrap/cms/ui.js index 68653dd3746..bc5209dea47 100644 --- a/resources/js/bootstrap/cms/ui.js +++ b/resources/js/bootstrap/cms/ui.js @@ -96,6 +96,7 @@ export { SplitterGroup, SplitterPanel, SplitterResizeHandle, + StarRating, StatusIndicator, Subheading, Switch, diff --git a/resources/js/components/fieldtypes/StarRatingFieldtype.vue b/resources/js/components/fieldtypes/StarRatingFieldtype.vue index c863da83eb7..a42949f91a9 100644 --- a/resources/js/components/fieldtypes/StarRatingFieldtype.vue +++ b/resources/js/components/fieldtypes/StarRatingFieldtype.vue @@ -1,68 +1,30 @@ diff --git a/resources/css/components/fieldtypes/star-rating.css b/resources/js/components/ui/StarRating/StarRating.vue similarity index 60% rename from resources/css/components/fieldtypes/star-rating.css rename to resources/js/components/ui/StarRating/StarRating.vue index a63b92450db..81a5095c365 100644 --- a/resources/css/components/fieldtypes/star-rating.css +++ b/resources/js/components/ui/StarRating/StarRating.vue @@ -1,6 +1,107 @@ + + + + + diff --git a/resources/js/components/ui/index.js b/resources/js/components/ui/index.js index 9527d5461c8..2993bd64e44 100644 --- a/resources/js/components/ui/index.js +++ b/resources/js/components/ui/index.js @@ -68,6 +68,7 @@ export { default as Skeleton } from './Skeleton.vue'; export { default as SplitterGroup } from './Splitter/Group.vue'; export { default as SplitterPanel } from './Splitter/Panel.vue'; export { default as SplitterResizeHandle } from './Splitter/ResizeHandle.vue'; +export { default as StarRating } from './StarRating/StarRating.vue'; export { default as Subheading } from './Subheading.vue'; export { default as Switch } from './Switch.vue'; export { default as TabContent } from './Tabs/Content.vue'; diff --git a/resources/js/stories/StarRating.stories.ts b/resources/js/stories/StarRating.stories.ts new file mode 100644 index 00000000000..1770fb83c98 --- /dev/null +++ b/resources/js/stories/StarRating.stories.ts @@ -0,0 +1,220 @@ +import type {Meta, StoryObj} from '@storybook/vue3'; +import {ref} from 'vue'; +import {expect, fn, userEvent, within} from 'storybook/test'; +import {Field, StarRating} from '@ui'; + +const meta = { + title: 'Forms/StarRating', + component: StarRating, + argTypes: { + size: { + control: 'select', + options: ['sm', 'base', 'lg'], + }, + 'update:modelValue': { + description: 'Event handler called when the rating changes.', + table: { + category: 'events', + type: { summary: '(value: number) => void' } + } + } + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const defaultCode = ` + +`; + +export const _DocsIntro: Story = { + tags: ['!dev'], + parameters: { + docs: { + source: { code: defaultCode } + } + }, + render: () => ({ + components: { StarRating }, + setup() { + const rating = ref(null); + return { rating }; + }, + template: defaultCode, + }), +}; + +const halfStarsCode = ` +
+ + + + + + +
+`; + +export const _HalfStars: Story = { + tags: ['!dev'], + parameters: { + docs: { + source: { code: halfStarsCode } + } + }, + render: () => ({ + components: { Field, StarRating }, + setup() { + const whole = ref(3); + const half = ref(3.5); + return { whole, half }; + }, + template: halfStarsCode, + }), +}; + +const maxCode = ` +
+ + + + + + +
+`; + +export const _Max: Story = { + tags: ['!dev'], + parameters: { + docs: { + source: { code: maxCode } + } + }, + render: () => ({ + components: { Field, StarRating }, + setup() { + const three = ref(2); + const ten = ref(7); + return { three, ten }; + }, + template: maxCode, + }), +}; + +const sizesCode = ` +
+ + + +
+`; + +export const _Sizes: Story = { + tags: ['!dev'], + parameters: { + docs: { + source: { code: sizesCode } + } + }, + render: () => ({ + components: { Field, StarRating }, + setup() { + const small = ref(4); + const base = ref(4); + const large = ref(4); + return { small, base, large }; + }, + template: sizesCode, + }), +}; + +const disabledCode = ` + +`; + +export const _Disabled: Story = { + tags: ['!dev'], + parameters: { + docs: { + source: { code: disabledCode } + } + }, + render: () => ({ + components: { StarRating }, + template: disabledCode, + }), +}; + +export const TestStartsUnrated: Story = { + tags: ['!dev', 'test'], + render: () => ({ + components: { StarRating }, + template: ``, + }), + play: async ({ canvasElement }) => { + const input = within(canvasElement).getByRole('slider') as HTMLInputElement; + + // The thumb sits at the minimum so it lines up with the first star, but nothing is filled in yet. + expect(input).toHaveAttribute('data-unrated'); + expect(input.value).toBe('1'); + }, +}; + +export const TestArrowKeySelectsMinimum: Story = { + tags: ['!dev', 'test'], + args: { + 'onUpdate:modelValue': fn(), + }, + render: (args) => ({ + components: { StarRating }, + setup() { + return { onUpdate: args['onUpdate:modelValue'] }; + }, + template: ``, + }), + play: async ({ canvasElement, args }) => { + const input = within(canvasElement).getByRole('slider') as HTMLInputElement; + + input.focus(); + await userEvent.keyboard('{ArrowRight}'); + + // Without intervention the browser would jump straight to two stars. + expect(args['onUpdate:modelValue']).toHaveBeenCalledWith(1); + expect(input).not.toHaveAttribute('data-unrated'); + }, +}; + +export const TestPointerRevealsTheFill: Story = { + tags: ['!dev', 'test'], + render: () => ({ + components: { StarRating }, + template: ``, + }), + play: async ({ canvasElement }) => { + const input = within(canvasElement).getByRole('slider') as HTMLInputElement; + + await userEvent.click(input); + + expect(input).not.toHaveAttribute('data-unrated'); + }, +}; + +export const TestHalfStars: Story = { + tags: ['!dev', 'test'], + render: () => ({ + components: { StarRating }, + template: ``, + }), + play: async ({ canvasElement }) => { + const input = within(canvasElement).getByRole('slider') as HTMLInputElement; + + // The minimum falls back to a single step, so half stars can start at 0.5. + expect(input.min).toBe('0.5'); + expect(input.max).toBe('10'); + expect(input.step).toBe('0.5'); + expect(input.value).toBe('3.5'); + expect(input).not.toHaveAttribute('data-unrated'); + }, +}; diff --git a/resources/js/stories/docs/StarRating.mdx b/resources/js/stories/docs/StarRating.mdx new file mode 100644 index 00000000000..78e2f278328 --- /dev/null +++ b/resources/js/stories/docs/StarRating.mdx @@ -0,0 +1,27 @@ +import { Canvas, Meta, ArgTypes } from '@storybook/addon-docs/blocks'; +import * as StarRatingStories from '../StarRating.stories'; + + + +# StarRating +A row of stars for collecting a rating. It's a native range input under the hood, so it's keyboard accessible. + +Nothing is filled in until the rating is set. The thumb still sits at the minimum so it lines up with the first star, but the fill stays hidden until the field is used or a value is provided. + + +## Half stars +Set `step` to `0.5` to allow ratings like 3.5. + + +## Number of stars +Use `max` to change how many stars are shown. + + +## Sizes + + +## Disabled + + +## Arguments + diff --git a/resources/js/tests/Package.test.js b/resources/js/tests/Package.test.js index 4da3ead2380..ec6295c2922 100644 --- a/resources/js/tests/Package.test.js +++ b/resources/js/tests/Package.test.js @@ -191,6 +191,7 @@ it('exports ui', async () => { 'SplitterGroup', 'SplitterPanel', 'SplitterResizeHandle', + 'StarRating', 'Subheading', 'Switch', 'TabContent', From 25cf7e4f7a286925c4ff5acafed324c130f3877a Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Thu, 23 Jul 2026 15:50:22 +0100 Subject: [PATCH 2/5] extract `RankList` into a ui component --- packages/cms/src/ui.js | 1 + resources/js/bootstrap/cms/ui.js | 1 + .../fieldtypes/RankingFieldtype.vue | 156 ++---------------- .../js/components/ui/RankList/RankList.vue | 146 ++++++++++++++++ resources/js/components/ui/index.js | 1 + resources/js/stories/RankList.stories.ts | 155 +++++++++++++++++ resources/js/stories/docs/RankList.mdx | 21 +++ resources/js/tests/Package.test.js | 1 + 8 files changed, 341 insertions(+), 141 deletions(-) create mode 100644 resources/js/components/ui/RankList/RankList.vue create mode 100644 resources/js/stories/RankList.stories.ts create mode 100644 resources/js/stories/docs/RankList.mdx diff --git a/packages/cms/src/ui.js b/packages/cms/src/ui.js index 1e5f8bdc30a..564b4568c4d 100644 --- a/packages/cms/src/ui.js +++ b/packages/cms/src/ui.js @@ -91,6 +91,7 @@ export const { PublishTabs, Radio, RadioGroup, + RankList, Select, Separator, Slider, diff --git a/resources/js/bootstrap/cms/ui.js b/resources/js/bootstrap/cms/ui.js index bc5209dea47..c311a2eb888 100644 --- a/resources/js/bootstrap/cms/ui.js +++ b/resources/js/bootstrap/cms/ui.js @@ -89,6 +89,7 @@ export { PublishTabs, Radio, RadioGroup, + RankList, Select, Separator, Slider, diff --git a/resources/js/components/fieldtypes/RankingFieldtype.vue b/resources/js/components/fieldtypes/RankingFieldtype.vue index a16e59f9858..479ef40898f 100644 --- a/resources/js/components/fieldtypes/RankingFieldtype.vue +++ b/resources/js/components/fieldtypes/RankingFieldtype.vue @@ -1,154 +1,28 @@ - - diff --git a/resources/js/components/ui/RankList/RankList.vue b/resources/js/components/ui/RankList/RankList.vue new file mode 100644 index 00000000000..103e6fbf694 --- /dev/null +++ b/resources/js/components/ui/RankList/RankList.vue @@ -0,0 +1,146 @@ + + + + + diff --git a/resources/js/components/ui/index.js b/resources/js/components/ui/index.js index 2993bd64e44..546d2349337 100644 --- a/resources/js/components/ui/index.js +++ b/resources/js/components/ui/index.js @@ -61,6 +61,7 @@ export { default as PanelHeader } from './Panel/Header.vue'; export { default as Popover } from './Popover.vue'; export { default as Radio } from './Radio/Item.vue'; export { default as RadioGroup } from './Radio/Group.vue'; +export { default as RankList } from './RankList/RankList.vue'; export { default as Select } from './Select/Select.vue'; export { default as Separator } from './Separator.vue'; export { default as Slider } from './Slider/Slider.vue'; diff --git a/resources/js/stories/RankList.stories.ts b/resources/js/stories/RankList.stories.ts new file mode 100644 index 00000000000..0f81b116912 --- /dev/null +++ b/resources/js/stories/RankList.stories.ts @@ -0,0 +1,155 @@ +import type {Meta, StoryObj} from '@storybook/vue3'; +import {ref} from 'vue'; +import {expect, fn, userEvent, within} from 'storybook/test'; +import {Field, RankList} from '@ui'; + +const meta = { + title: 'Forms/RankList', + component: RankList, + argTypes: { + 'update:modelValue': { + description: 'Event handler called when the order changes.', + table: { + category: 'events', + type: { summary: '(value: string[]) => void' } + } + } + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const seasons = [ + { value: 'spring', label: 'Spring' }, + { value: 'summer', label: 'Summer' }, + { value: 'autumn', label: 'Autumn' }, + { value: 'winter', label: 'Winter' }, +]; + +const defaultCode = ` + +`; + +export const _DocsIntro: Story = { + tags: ['!dev'], + parameters: { + docs: { + source: { code: defaultCode } + } + }, + render: () => ({ + components: { RankList }, + setup() { + const order = ref([]); + return { order, options: seasons }; + }, + template: defaultCode, + }), +}; + +const orderedCode = ` + + + +`; + +export const _ExistingOrder: Story = { + tags: ['!dev'], + parameters: { + docs: { + source: { code: orderedCode } + } + }, + render: () => ({ + components: { Field, RankList }, + setup() { + const order = ref(['summer', 'spring', 'winter', 'autumn']); + return { order, options: seasons }; + }, + template: orderedCode, + }), +}; + +const disabledCode = ` + +`; + +export const _Disabled: Story = { + tags: ['!dev'], + parameters: { + docs: { + source: { code: disabledCode } + } + }, + render: () => ({ + components: { RankList }, + setup() { + return { order: ['winter', 'autumn', 'summer', 'spring'], options: seasons }; + }, + template: disabledCode, + }), +}; + +export const TestFallsBackToOptionOrder: Story = { + tags: ['!dev', 'test'], + render: () => ({ + components: { RankList }, + setup() { + return { options: seasons }; + }, + template: ``, + }), + play: async ({ canvasElement }) => { + const items = within(canvasElement).getAllByRole('listitem'); + + expect(items.map((item) => item.textContent?.trim())).toEqual(['Spring', 'Summer', 'Autumn', 'Winter']); + }, +}; + +export const TestTypingARankReorders: Story = { + tags: ['!dev', 'test'], + args: { + 'onUpdate:modelValue': fn(), + }, + render: (args) => ({ + components: { RankList }, + setup() { + return { options: seasons, onUpdate: args['onUpdate:modelValue'] }; + }, + template: ``, + }), + play: async ({ canvasElement, args }) => { + const input = within(canvasElement).getByLabelText('Rank Winter') as HTMLInputElement; + + await userEvent.clear(input); + await userEvent.type(input, '1'); + await userEvent.tab(); + + expect(args['onUpdate:modelValue']).toHaveBeenCalledWith(['winter', 'spring', 'summer', 'autumn']); + }, +}; + +export const TestOutOfRangeRankIsClamped: Story = { + tags: ['!dev', 'test'], + args: { + 'onUpdate:modelValue': fn(), + }, + render: (args) => ({ + components: { RankList }, + setup() { + return { options: seasons, onUpdate: args['onUpdate:modelValue'] }; + }, + template: ``, + }), + play: async ({ canvasElement, args }) => { + const input = within(canvasElement).getByLabelText('Rank Spring') as HTMLInputElement; + + await userEvent.clear(input); + await userEvent.type(input, '9'); + await userEvent.tab(); + + expect(args['onUpdate:modelValue']).toHaveBeenCalledWith(['summer', 'autumn', 'winter', 'spring']); + expect(input.value).toBe('4'); + }, +}; diff --git a/resources/js/stories/docs/RankList.mdx b/resources/js/stories/docs/RankList.mdx new file mode 100644 index 00000000000..fb868d47ccf --- /dev/null +++ b/resources/js/stories/docs/RankList.mdx @@ -0,0 +1,21 @@ +import { Canvas, Meta, ArgTypes } from '@storybook/addon-docs/blocks'; +import * as RankListStories from '../RankList.stories'; + + + +# RankList +An ordered list of items that can be rearranged by dragging, or by typing a position into each item's rank input. + +The value is an array of option values in their ranked order. When it's empty, or missing some of the options, the list falls back to the order the options were given in. + + +## Existing order +Pass an order to start from. The drag handles highlight once the list differs from the original order. + + +## Disabled +A disabled list can't be reordered. The rank inputs are replaced with plain numbers. + + +## Arguments + diff --git a/resources/js/tests/Package.test.js b/resources/js/tests/Package.test.js index ec6295c2922..eadcea6081e 100644 --- a/resources/js/tests/Package.test.js +++ b/resources/js/tests/Package.test.js @@ -184,6 +184,7 @@ it('exports ui', async () => { 'Popover', 'Radio', 'RadioGroup', + 'RankList', 'Select', 'Separator', 'Skeleton', From c5b8b2dbd9c0c94e91f1edea7cfc0d59cb562ff3 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Thu, 23 Jul 2026 15:54:10 +0100 Subject: [PATCH 3/5] extract `ChoiceList` into a ui component from the image choice fieldtype --- packages/cms/src/ui.js | 1 + resources/js/bootstrap/cms/ui.js | 1 + .../fieldtypes/ImageChoiceFieldtype.vue | 138 +++--------- .../components/ui/ChoiceGrid/ChoiceGrid.vue | 130 +++++++++++ resources/js/components/ui/index.js | 1 + resources/js/stories/ChoiceGrid.stories.ts | 211 ++++++++++++++++++ resources/js/stories/docs/ChoiceGrid.mdx | 29 +++ resources/js/tests/Package.test.js | 1 + 8 files changed, 411 insertions(+), 101 deletions(-) create mode 100644 resources/js/components/ui/ChoiceGrid/ChoiceGrid.vue create mode 100644 resources/js/stories/ChoiceGrid.stories.ts create mode 100644 resources/js/stories/docs/ChoiceGrid.mdx diff --git a/packages/cms/src/ui.js b/packages/cms/src/ui.js index 564b4568c4d..1cce409c7f4 100644 --- a/packages/cms/src/ui.js +++ b/packages/cms/src/ui.js @@ -13,6 +13,7 @@ export const { CharacterCounter, Checkbox, CheckboxGroup, + ChoiceGrid, CodeEditor, Combobox, CommandPaletteItem, diff --git a/resources/js/bootstrap/cms/ui.js b/resources/js/bootstrap/cms/ui.js index c311a2eb888..a38890b0bf4 100644 --- a/resources/js/bootstrap/cms/ui.js +++ b/resources/js/bootstrap/cms/ui.js @@ -13,6 +13,7 @@ export { CharacterCounter, Checkbox, CheckboxGroup, + ChoiceGrid, CodeEditor, Combobox, CommandPaletteItem, diff --git a/resources/js/components/fieldtypes/ImageChoiceFieldtype.vue b/resources/js/components/fieldtypes/ImageChoiceFieldtype.vue index cbf8878bfe8..8ea477f8f64 100644 --- a/resources/js/components/fieldtypes/ImageChoiceFieldtype.vue +++ b/resources/js/components/fieldtypes/ImageChoiceFieldtype.vue @@ -1,115 +1,51 @@ diff --git a/resources/js/components/ui/ChoiceGrid/ChoiceGrid.vue b/resources/js/components/ui/ChoiceGrid/ChoiceGrid.vue new file mode 100644 index 00000000000..7a30aeed004 --- /dev/null +++ b/resources/js/components/ui/ChoiceGrid/ChoiceGrid.vue @@ -0,0 +1,130 @@ + + + diff --git a/resources/js/components/ui/index.js b/resources/js/components/ui/index.js index 546d2349337..2216de92575 100644 --- a/resources/js/components/ui/index.js +++ b/resources/js/components/ui/index.js @@ -11,6 +11,7 @@ export { default as CardPanel } from './Card/Panel.vue'; export { default as CharacterCounter } from './CharacterCounter.vue'; export { default as Checkbox } from './Checkbox/Item.vue'; export { default as CheckboxGroup } from './Checkbox/Group.vue'; +export { default as ChoiceGrid } from './ChoiceGrid/ChoiceGrid.vue'; export { default as CodeEditor } from './CodeEditor.vue'; export { default as Combobox } from './Combobox/Combobox.vue'; export { default as ConfirmationModal } from './Modal/ConfirmationModal.vue'; diff --git a/resources/js/stories/ChoiceGrid.stories.ts b/resources/js/stories/ChoiceGrid.stories.ts new file mode 100644 index 00000000000..2bc8cf12aa1 --- /dev/null +++ b/resources/js/stories/ChoiceGrid.stories.ts @@ -0,0 +1,211 @@ +import type {Meta, StoryObj} from '@storybook/vue3'; +import {ref} from 'vue'; +import {expect, fn, userEvent, within} from 'storybook/test'; +import {ChoiceGrid} from '@ui'; + +const meta = { + title: 'Forms/ChoiceGrid', + component: ChoiceGrid, + argTypes: { + gap: { + control: 'select', + options: ['sm', 'base', 'lg'], + }, + 'update:modelValue': { + description: 'Event handler called when the selection changes.', + table: { + category: 'events', + type: { summary: '(value: string | string[]) => void' } + } + } + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const seasons = [ + { value: 'spring', label: 'Spring', image: 'https://picsum.photos/seed/choice-grid-spring/640/480' }, + { value: 'summer', label: 'Summer', image: 'https://picsum.photos/seed/choice-grid-summer/640/480' }, + { value: 'autumn', label: 'Autumn', image: 'https://picsum.photos/seed/choice-grid-autumn/640/480' }, + { value: 'winter', label: 'Winter', image: 'https://picsum.photos/seed/choice-grid-winter/640/480' }, +]; + +const defaultCode = ` + +`; + +export const _DocsIntro: Story = { + tags: ['!dev'], + parameters: { + docs: { + source: { code: defaultCode } + } + }, + render: () => ({ + components: { ChoiceGrid }, + setup() { + const season = ref('summer'); + return { season, options: seasons }; + }, + template: defaultCode, + }), +}; + +const multipleCode = ` + +`; + +export const _Multiple: Story = { + tags: ['!dev'], + parameters: { + docs: { + source: { code: multipleCode } + } + }, + render: () => ({ + components: { ChoiceGrid }, + setup() { + const chosen = ref(['spring', 'autumn']); + return { chosen, options: seasons }; + }, + template: multipleCode, + }), +}; + +const badgesCode = ` + +`; + +export const _Badges: Story = { + tags: ['!dev'], + parameters: { + docs: { + source: { code: badgesCode } + } + }, + render: () => ({ + components: { ChoiceGrid }, + setup() { + const answer = ref(null); + const options = seasons.slice(0, 3).map((option, index) => ({ + ...option, + badge: String.fromCharCode(65 + index), + })); + return { answer, options }; + }, + template: badgesCode, + }), +}; + +const aspectRatioCode = ` + +`; + +export const _AspectRatio: Story = { + tags: ['!dev'], + parameters: { + docs: { + source: { code: aspectRatioCode } + } + }, + render: () => ({ + components: { ChoiceGrid }, + setup() { + const season = ref('winter'); + return { season, options: seasons }; + }, + template: aspectRatioCode, + }), +}; + +const placeholderCode = ` + +`; + +export const _WithoutImages: Story = { + tags: ['!dev'], + parameters: { + docs: { + source: { code: placeholderCode } + } + }, + render: () => ({ + components: { ChoiceGrid }, + setup() { + const answer = ref(null); + const options = [ + { value: 'one', label: 'Awaiting artwork' }, + { value: 'two', label: 'Also awaiting artwork' }, + ]; + return { answer, options }; + }, + template: placeholderCode, + }), +}; + +export const TestSelectsSingleOption: Story = { + tags: ['!dev', 'test'], + args: { + 'onUpdate:modelValue': fn(), + }, + render: (args) => ({ + components: { ChoiceGrid }, + setup() { + return { options: seasons, onUpdate: args['onUpdate:modelValue'] }; + }, + template: ``, + }), + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + + await userEvent.click(canvas.getByText('Autumn')); + + expect(args['onUpdate:modelValue']).toHaveBeenCalledWith('autumn'); + expect(canvas.getAllByRole('radio')).toHaveLength(4); + }, +}; + +export const TestTogglesMultipleOptions: Story = { + tags: ['!dev', 'test'], + args: { + 'onUpdate:modelValue': fn(), + }, + render: (args) => ({ + components: { ChoiceGrid }, + setup() { + return { options: seasons, onUpdate: args['onUpdate:modelValue'] }; + }, + template: ``, + }), + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + + await userEvent.click(canvas.getByText('Winter')); + expect(args['onUpdate:modelValue']).toHaveBeenCalledWith(['spring', 'winter']); + + await userEvent.click(canvas.getByText('Spring')); + expect(args['onUpdate:modelValue']).toHaveBeenCalledWith([]); + }, +}; + +export const TestDisabledOptionsCannotBeSelected: Story = { + tags: ['!dev', 'test'], + args: { + 'onUpdate:modelValue': fn(), + }, + render: (args) => ({ + components: { ChoiceGrid }, + setup() { + return { options: seasons, onUpdate: args['onUpdate:modelValue'] }; + }, + template: ``, + }), + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + + await userEvent.click(canvas.getByText('Summer')); + + expect(args['onUpdate:modelValue']).not.toHaveBeenCalled(); + }, +}; diff --git a/resources/js/stories/docs/ChoiceGrid.mdx b/resources/js/stories/docs/ChoiceGrid.mdx new file mode 100644 index 00000000000..928a428731c --- /dev/null +++ b/resources/js/stories/docs/ChoiceGrid.mdx @@ -0,0 +1,29 @@ +import { Canvas, Meta, ArgTypes } from '@storybook/addon-docs/blocks'; +import * as ChoiceGridStories from '../ChoiceGrid.stories'; + + + +# ChoiceGrid +A grid of image-led cards for picking between visual options. Each card is a real radio or checkbox, so selection works with the keyboard and inside a regular form. + +Options are objects with a `value`, and optionally a `label`, `image` and `badge`. + + +## Multiple +Add `multiple` to allow more than one selection. The value becomes an array of the selected option values. + + +## Badges +An option's `badge` is shown alongside its label. Handy for lettering or numbering the choices. + + +## Columns, gaps and aspect ratios +Use `columns` to set how many options appear per row, `gap` to control the spacing between them, and `aspect-ratio` to change the shape of each image area. + + +## Missing images +Options without an `image` fall back to a placeholder, so a partially configured grid still lines up. + + +## Arguments + diff --git a/resources/js/tests/Package.test.js b/resources/js/tests/Package.test.js index eadcea6081e..5d95c36e2ca 100644 --- a/resources/js/tests/Package.test.js +++ b/resources/js/tests/Package.test.js @@ -135,6 +135,7 @@ it('exports ui', async () => { 'CharacterCounter', 'Checkbox', 'CheckboxGroup', + 'ChoiceGrid', 'CodeEditor', 'Combobox', 'ConfirmationModal', From fe54a634b59c097547eda057fa20170a12772182 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Thu, 23 Jul 2026 15:55:54 +0100 Subject: [PATCH 4/5] they don't need folders --- resources/js/components/ui/{ChoiceGrid => }/ChoiceGrid.vue | 2 +- resources/js/components/ui/{RankList => }/RankList.vue | 4 ++-- resources/js/components/ui/{StarRating => }/StarRating.vue | 2 +- resources/js/components/ui/index.js | 6 +++--- 4 files changed, 7 insertions(+), 7 deletions(-) rename resources/js/components/ui/{ChoiceGrid => }/ChoiceGrid.vue (99%) rename resources/js/components/ui/{RankList => }/RankList.vue (98%) rename resources/js/components/ui/{StarRating => }/StarRating.vue (99%) diff --git a/resources/js/components/ui/ChoiceGrid/ChoiceGrid.vue b/resources/js/components/ui/ChoiceGrid.vue similarity index 99% rename from resources/js/components/ui/ChoiceGrid/ChoiceGrid.vue rename to resources/js/components/ui/ChoiceGrid.vue index 7a30aeed004..5b5aed3664e 100644 --- a/resources/js/components/ui/ChoiceGrid/ChoiceGrid.vue +++ b/resources/js/components/ui/ChoiceGrid.vue @@ -1,7 +1,7 @@