diff --git a/packages/cms/src/ui.js b/packages/cms/src/ui.js index 3f5150061c9..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, @@ -91,6 +92,7 @@ export const { PublishTabs, Radio, RadioGroup, + RankList, Select, Separator, Slider, @@ -98,6 +100,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..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, @@ -89,6 +90,7 @@ export { PublishTabs, Radio, RadioGroup, + RankList, Select, Separator, Slider, @@ -96,6 +98,7 @@ export { SplitterGroup, SplitterPanel, SplitterResizeHandle, + StarRating, StatusIndicator, Subheading, Switch, 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/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/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/js/components/ui/ChoiceGrid.vue b/resources/js/components/ui/ChoiceGrid.vue new file mode 100644 index 00000000000..5b5aed3664e --- /dev/null +++ b/resources/js/components/ui/ChoiceGrid.vue @@ -0,0 +1,130 @@ + + + diff --git a/resources/js/components/ui/RankList.vue b/resources/js/components/ui/RankList.vue new file mode 100644 index 00000000000..fdfafb77af1 --- /dev/null +++ b/resources/js/components/ui/RankList.vue @@ -0,0 +1,146 @@ + + + + + diff --git a/resources/css/components/fieldtypes/star-rating.css b/resources/js/components/ui/StarRating.vue similarity index 60% rename from resources/css/components/fieldtypes/star-rating.css rename to resources/js/components/ui/StarRating.vue index a63b92450db..947eff50e8e 100644 --- a/resources/css/components/fieldtypes/star-rating.css +++ b/resources/js/components/ui/StarRating.vue @@ -1,6 +1,107 @@ + + + + + diff --git a/resources/js/components/ui/index.js b/resources/js/components/ui/index.js index 9527d5461c8..504fc178fc1 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.vue'; export { default as CodeEditor } from './CodeEditor.vue'; export { default as Combobox } from './Combobox/Combobox.vue'; export { default as ConfirmationModal } from './Modal/ConfirmationModal.vue'; @@ -61,6 +62,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.vue'; export { default as Select } from './Select/Select.vue'; export { default as Separator } from './Separator.vue'; export { default as Slider } from './Slider/Slider.vue'; @@ -68,6 +70,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.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/ChoiceGrid.stories.ts b/resources/js/stories/ChoiceGrid.stories.ts new file mode 100644 index 00000000000..84074ba5248 --- /dev/null +++ b/resources/js/stories/ChoiceGrid.stories.ts @@ -0,0 +1,216 @@ +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; + +// Inline, so the stories don't depend on the network when they run in CI. +const image = (hue: number) => `data:image/svg+xml,${encodeURIComponent( + `` +)}`; + +const seasons = [ + { value: 'spring', label: 'Spring', image: image(110) }, + { value: 'summer', label: 'Summer', image: image(45) }, + { value: 'autumn', label: 'Autumn', image: image(20) }, + { value: 'winter', label: 'Winter', image: image(210) }, +]; + +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/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/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/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/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/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..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', @@ -184,6 +185,7 @@ it('exports ui', async () => { 'Popover', 'Radio', 'RadioGroup', + 'RankList', 'Select', 'Separator', 'Skeleton', @@ -191,6 +193,7 @@ it('exports ui', async () => { 'SplitterGroup', 'SplitterPanel', 'SplitterResizeHandle', + 'StarRating', 'Subheading', 'Switch', 'TabContent',