Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@
"./editor-style.css": {
"import": "./src/components/TextEditor/style.css"
},
"./hljs-theme.css": {
"import": "./src/components/TextEditor/hljs-github.css"
},
"./tsconfig.base.json": {
"types": "./tsconfig.base.json",
"default": "./tsconfig.base.json"
Expand Down Expand Up @@ -188,4 +191,4 @@
"lint-staged": {
"*.{js,css,md,vue}": "prettier --write"
}
}
}
89 changes: 89 additions & 0 deletions src/components/SettingsDialog/SettingsDialog.api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
<!-- Auto Generated by scripts/propsgen.ts -->
<script setup>
import PropsTable from '@/components/Docs/PropsTable.vue'
import SlotsTable from '@/components/Docs/SlotsTable.vue'
import EmitsTable from '@/components/Docs/EmitsTable.vue'

const settingsDialogProps = [
{
name: 'sections',
description: 'Sidebar sections to render. Each item may carry a `component` that is\nshown in the content area when the item is selected.',
required: true,
type: 'SettingsSection[]'
},
{
name: 'size',
description: 'Max-width size of the dialog.',
required: false,
type: 'DialogSize',
default: '"4xl"'
},
{
name: 'modelValue',
description: 'Controls whether the dialog is open.',
required: false,
type: 'boolean',
default: 'false'
}
]

const settingsDialogSlots = [
{
name: 'tab-content',
description: 'Overrides the content area. Receives the active `{ tab }`.',
type: '{ tab: SettingsTab | undefined; }'
}
]

const settingsDialogEmits = [
{
name: 'update:modelValue',
description: 'Fired when the model value changes.',
type: '[value: boolean]'
}
]

const settingsPanelProps = [
{
name: 'title',
description: 'Heading rendered at the top of the panel.',
required: true,
type: 'string'
},
{
name: 'description',
description: 'Optional sub-heading rendered below the title.',
required: false,
type: 'string'
}
]

const settingsPanelSlots = [
{
name: 'default',
description: 'Panel body — the settings controls for this tab.',
type: 'any'
},
{
name: 'actions',
description: 'Actions rendered on the right of the panel header.',
type: 'any'
}
]
</script>
## API Reference

### SettingsDialog

<PropsTable name="SettingsDialog" :data="settingsDialogProps"/>

<SlotsTable :data="settingsDialogSlots"/>

<EmitsTable :data="settingsDialogEmits"/>

### SettingsPanel

<PropsTable folder="SettingsDialog" name="SettingsPanel" :data="settingsPanelProps"/>

<SlotsTable :data="settingsPanelSlots"/>

90 changes: 90 additions & 0 deletions src/components/SettingsDialog/SettingsDialog.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { defineComponent, h } from 'vue'
import SettingsDialog from './SettingsDialog.vue'
import type { SettingsSection } from './types'

const panel = (text: string) =>
defineComponent({ render: () => h('div', { class: 'tab-panel' }, text) })

function makeSections(onProfileClick?: () => void): SettingsSection[] {
return [
{
label: 'Account',
items: [
{
label: 'Profile',
icon: 'lucide-circle-user',
component: panel('Profile content'),
onClick: onProfileClick,
},
],
},
{
label: 'Workspace',
items: [
{ label: 'General', icon: 'lucide-settings', component: panel('General content') },
{ label: 'Members', icon: 'lucide-users', component: panel('Members content') },
],
},
]
}

const sections = makeSections()

describe('SettingsDialog', () => {
it('does not render while closed; renders when open (v-model)', () => {
cy.mount(SettingsDialog, { props: { modelValue: false, sections } })
cy.get('[role=dialog]').should('not.exist')

cy.mount(SettingsDialog, { props: { modelValue: true, sections } })
cy.get('[role=dialog]').should('exist')
})

it('renders all section items in the sidebar', () => {
cy.mount(SettingsDialog, { props: { modelValue: true, sections } })
cy.get('[role=dialog]').within(() => {
cy.contains('Profile').should('exist')
cy.contains('General').should('exist')
cy.contains('Members').should('exist')
})
})

it('shows the first item content by default', () => {
cy.mount(SettingsDialog, { props: { modelValue: true, sections } })
cy.get('[role=dialog] .tab-panel').should('have.text', 'Profile content')
})

it('switches the active tab on click and invokes the item onClick', () => {
const onProfileClick = cy.stub().as('onProfileClick')
cy.mount(SettingsDialog, {
props: { modelValue: true, sections: makeSections(onProfileClick) },
})
cy.get('[role=dialog] .tab-panel').should('have.text', 'Profile content')

cy.get('[role=dialog]').contains('Members').click()
cy.get('[role=dialog] .tab-panel').should('have.text', 'Members content')

cy.get('[role=dialog]').contains('Profile').click()
cy.get('@onProfileClick').should('have.been.called')
})

it('overrides the content area via the #tab-content slot', () => {
cy.mount(SettingsDialog, {
props: { modelValue: true, sections },
slots: {
'tab-content': (slotProps: { tab?: { label: string } }) =>
h('div', { class: 'custom-content' }, `Custom: ${slotProps.tab?.label}`),
},
})
cy.get('[role=dialog] .custom-content').should('have.text', 'Custom: Profile')
})

it('emits update:modelValue when the dialog is closed', () => {
const onUpdate = cy.spy().as('onUpdate')
cy.mount(SettingsDialog, {
props: { modelValue: true, sections, 'onUpdate:modelValue': onUpdate },
})
cy.get('[role=dialog]').should('exist')
cy.get('body').type('{esc}')
cy.get('@onUpdate').should('have.been.calledWith', false)
})
})
31 changes: 31 additions & 0 deletions src/components/SettingsDialog/SettingsDialog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# SettingsDialog

A modal for app settings: a sidebar of grouped, navigable items on the left
and the active item's content on the right. Built on top of `Dialog` and
`Sidebar`.

<ComponentPreview name="SettingsDialog-Default" layout="stacked" />

## Sections and items

Pass `sections`, each with a `label` and a list of `items`. Every item is a
sidebar entry; give it a `component` to render in the content area when it is
selected. The first item is active by default.

## Custom tab content

Use the `#tab-content` slot to render the content area yourself instead of
per-item `component`s. The slot receives the active `{ tab }`.

<ComponentPreview name="SettingsDialog-CustomTabContent" />

## SettingsPanel

`SettingsPanel` is the chrome for a single settings tab: a padded, scrollable
container with a title, an optional description, an `#actions` area, and a body
slot. Render one per tab — it pairs naturally with each `sections` item's
`component`.

<ComponentPreview name="SettingsDialog-PanelBasic" />

<!-- @include: ./SettingsDialog.api.md -->
49 changes: 49 additions & 0 deletions src/components/SettingsDialog/SettingsDialog.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<template>
<Dialog v-model="modelValue" :size="size" bare>
<div class="flex" style="height: calc(100vh - 12rem)">
<Sidebar class="w-52" :sections="sidebarSections" disable-collapse />
<div class="flex h-full flex-1 flex-col overflow-hidden">
<slot name="tab-content" :tab="activeTab">
<component v-if="activeTab?.component" :is="activeTab.component" />
</slot>
</div>
</div>
</Dialog>
</template>

<script setup lang="ts">
import { computed, shallowRef } from 'vue'
import { Dialog } from '../Dialog'
import { Sidebar } from '../Sidebar'
import type { SettingsDialogProps, SettingsTab } from './types'

const props = withDefaults(defineProps<SettingsDialogProps>(), {
size: '4xl',
})

/** Controls whether the dialog is open. */
const modelValue = defineModel<boolean>({ default: false })

defineSlots<{
/** Overrides the content area. Receives the active `{ tab }`. */
'tab-content'?: (props: { tab: SettingsTab | undefined }) => any
}>()

const allItems = computed(() => props.sections.flatMap((s) => s.items))

const activeTab = shallowRef<SettingsTab | undefined>(allItems.value[0])

const sidebarSections = computed(() =>
props.sections.map((section) => ({
...section,
items: section.items.map((item) => ({
...item,
isActive: activeTab.value?.label === item.label,
onClick: () => {
activeTab.value = item
item.onClick?.()
},
})),
})),
)
</script>
40 changes: 40 additions & 0 deletions src/components/SettingsDialog/SettingsPanel.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { h } from 'vue'
import SettingsPanel from './SettingsPanel.vue'

describe('SettingsPanel', () => {
it('renders the title', () => {
cy.mount(SettingsPanel, { props: { title: 'General' } })
cy.contains('h1', 'General').should('exist')
})

it('renders the description when provided', () => {
cy.mount(SettingsPanel, {
props: { title: 'General', description: 'Workspace preferences.' },
})
cy.contains('Workspace preferences.').should('exist')
})

it('omits the description when not provided', () => {
cy.mount(SettingsPanel, { props: { title: 'General' } })
cy.get('p').should('not.exist')
})

it('renders the default slot as the body', () => {
cy.mount(SettingsPanel, {
props: { title: 'General' },
slots: { default: () => h('div', { class: 'body' }, 'Body content') },
})
cy.get('.body').should('have.text', 'Body content')
})

it('renders the actions slot only when provided', () => {
cy.mount(SettingsPanel, { props: { title: 'General' } })
cy.get('.action').should('not.exist')

cy.mount(SettingsPanel, {
props: { title: 'General' },
slots: { actions: () => h('button', { class: 'action' }, 'Save') },
})
cy.get('.action').should('have.text', 'Save')
})
})
34 changes: 34 additions & 0 deletions src/components/SettingsDialog/SettingsPanel.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<template>
<div class="flex h-full w-full flex-col overflow-y-auto p-8">
<div class="flex items-start justify-between gap-4">
<div class="flex flex-col gap-1">
<h1 class="text-xl font-semibold text-ink-gray-9">{{ title }}</h1>
<p v-if="description" class="text-p-base text-ink-gray-6">
{{ description }}
</p>
</div>
<div
v-if="$slots.actions"
class="flex flex-shrink-0 items-center gap-2"
>
<slot name="actions" />
</div>
</div>
<div class="mt-6 flex flex-1 flex-col gap-6">
<slot />
</div>
</div>
</template>

<script setup lang="ts">
import type { SettingsPanelProps } from './types'

defineProps<SettingsPanelProps>()

defineSlots<{
/** Panel body — the settings controls for this tab. */
default?: () => any
/** Actions rendered on the right of the panel header. */
actions?: () => any
}>()
</script>
8 changes: 8 additions & 0 deletions src/components/SettingsDialog/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export { default as SettingsDialog } from './SettingsDialog.vue'
export { default as SettingsPanel } from './SettingsPanel.vue'
export type {
SettingsDialogProps,
SettingsPanelProps,
SettingsTab,
SettingsSection,
} from './types'
34 changes: 34 additions & 0 deletions src/components/SettingsDialog/stories/CustomTabContent.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<script setup lang="ts">
import { ref } from 'vue'
import { Button, SettingsDialog } from 'frappe-ui'
import type { SettingsSection } from 'frappe-ui'

// Instead of per-item `component`s, render the content area yourself via the
// `#tab-content` slot. The slot receives the active `{ tab }`.
const open = ref(false)

const sections: SettingsSection[] = [
{
label: 'Preferences',
items: [
{ label: 'Appearance', icon: 'lucide-palette' },
{ label: 'Notifications', icon: 'lucide-bell' },
{ label: 'Language', icon: 'lucide-languages' },
],
},
]
</script>

<template>
<Button @click="open = true">Open settings</Button>
<SettingsDialog v-model="open" :sections="sections">
<template #tab-content="{ tab }">
<div class="p-6">
<h2 class="text-lg font-semibold text-ink-gray-9">{{ tab?.label }}</h2>
<p class="mt-1 text-base text-ink-gray-6">
Custom content for the “{{ tab?.label }}” tab.
</p>
</div>
</template>
</SettingsDialog>
</template>
Loading
Loading