diff --git a/next-ui/src/components.d.ts b/next-ui/src/components.d.ts
index c920afe8..ea7da51b 100644
--- a/next-ui/src/components.d.ts
+++ b/next-ui/src/components.d.ts
@@ -71,6 +71,8 @@ declare module 'vue' {
PageHashKnownTable: typeof import('./components/pageHash/KnownTable.vue')['default']
PageHashMatchTable: typeof import('./components/pageHash/MatchTable.vue')['default']
PageHashUnknownTable: typeof import('./components/pageHash/UnknownTable.vue')['default']
+ PageSizeSelector: typeof import('./components/PageSizeSelector.vue')['default']
+ PresentationSelector: typeof import('./components/PresentationSelector.vue')['default']
ReleaseCard: typeof import('./components/release/Card.vue')['default']
RemoteFileList: typeof import('./components/RemoteFileList.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
diff --git a/next-ui/src/components/PageSizeSelector.mdx b/next-ui/src/components/PageSizeSelector.mdx
new file mode 100644
index 00000000..dbc79722
--- /dev/null
+++ b/next-ui/src/components/PageSizeSelector.mdx
@@ -0,0 +1,11 @@
+import { Canvas, Meta } from '@storybook/addon-docs/blocks';
+
+import * as Stories from './PageSizeSelector.stories';
+
+
+
+# PageSizeSelector
+
+A button that will display a list of page sizes when clicked, optionally allowing unpaged content.
+
+
diff --git a/next-ui/src/components/PageSizeSelector.stories.ts b/next-ui/src/components/PageSizeSelector.stories.ts
new file mode 100644
index 00000000..1e4e682a
--- /dev/null
+++ b/next-ui/src/components/PageSizeSelector.stories.ts
@@ -0,0 +1,62 @@
+import type { Meta, StoryObj } from '@storybook/vue3-vite'
+
+import PageSizeSelector from './PageSizeSelector.vue'
+import { expect, fn } from 'storybook/test'
+
+const meta = {
+ component: PageSizeSelector,
+ render: (args: object) => ({
+ components: { PageSizeSelector },
+ setup() {
+ return { args }
+ },
+ template: '',
+ }),
+ parameters: {
+ // More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
+ },
+ args: {
+ modelValue: 20,
+ 'onUpdate:modelValue': fn(),
+ },
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const Default: Story = {
+ args: {},
+}
+
+export const Clicked: Story = {
+ args: {},
+ play: async ({ canvas, userEvent }) => {
+ await expect(canvas.getByRole('button')).toBeEnabled()
+
+ await userEvent.click(canvas.getByRole('button'))
+ },
+}
+
+export const Unpaged: Story = {
+ args: {
+ modelValue: 'unpaged',
+ allowUnpaged: true,
+ },
+ play: async ({ canvas, userEvent }) => {
+ await expect(canvas.getByRole('button')).toBeEnabled()
+
+ await userEvent.click(canvas.getByRole('button'))
+ },
+}
+
+export const CustomSizes: Story = {
+ args: {
+ allowUnpaged: true,
+ sizes: [1, 2, 3, 4, 5],
+ },
+ play: async ({ canvas, userEvent }) => {
+ await expect(canvas.getByRole('button')).toBeEnabled()
+
+ await userEvent.click(canvas.getByRole('button'))
+ },
+}
diff --git a/next-ui/src/components/PageSizeSelector.vue b/next-ui/src/components/PageSizeSelector.vue
new file mode 100644
index 00000000..0e16de63
--- /dev/null
+++ b/next-ui/src/components/PageSizeSelector.vue
@@ -0,0 +1,58 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/next-ui/src/components/PresentationSelector.mdx b/next-ui/src/components/PresentationSelector.mdx
new file mode 100644
index 00000000..55700384
--- /dev/null
+++ b/next-ui/src/components/PresentationSelector.mdx
@@ -0,0 +1,11 @@
+import { Canvas, Meta } from '@storybook/addon-docs/blocks';
+
+import * as Stories from './PresentationSelector.stories';
+
+
+
+# PresentationSelector
+
+A button that will display a list of presentation modes when clicked.
+
+
diff --git a/next-ui/src/components/PresentationSelector.stories.ts b/next-ui/src/components/PresentationSelector.stories.ts
new file mode 100644
index 00000000..7a30680b
--- /dev/null
+++ b/next-ui/src/components/PresentationSelector.stories.ts
@@ -0,0 +1,50 @@
+import type { Meta, StoryObj } from '@storybook/vue3-vite'
+
+import PresentationSelector from './PresentationSelector.vue'
+import { expect, fn } from 'storybook/test'
+
+const meta = {
+ component: PresentationSelector,
+ render: (args: object) => ({
+ components: { PresentationSelector },
+ setup() {
+ return { args }
+ },
+ template: '',
+ }),
+ parameters: {
+ // More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
+ },
+ args: {
+ modelValue: 'grid',
+ modes: ['grid', 'list', 'table'],
+ 'onUpdate:modelValue': fn(),
+ },
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const Default: Story = {
+ args: {},
+}
+
+export const Clicked: Story = {
+ args: {},
+ play: async ({ canvas, userEvent }) => {
+ await expect(canvas.getByRole('button')).toBeEnabled()
+
+ await userEvent.click(canvas.getByRole('button'))
+ },
+}
+
+export const LimitedSet: Story = {
+ args: {
+ modes: ['grid', 'list'],
+ },
+ play: async ({ canvas, userEvent }) => {
+ await expect(canvas.getByRole('button')).toBeEnabled()
+
+ await userEvent.click(canvas.getByRole('button'))
+ },
+}
diff --git a/next-ui/src/components/PresentationSelector.vue b/next-ui/src/components/PresentationSelector.vue
new file mode 100644
index 00000000..93457cde
--- /dev/null
+++ b/next-ui/src/components/PresentationSelector.vue
@@ -0,0 +1,75 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/next-ui/src/types/page.ts b/next-ui/src/types/page.ts
new file mode 100644
index 00000000..18f1ec31
--- /dev/null
+++ b/next-ui/src/types/page.ts
@@ -0,0 +1 @@
+export type PageSize = 'unpaged' | number