diff --git a/package.json b/package.json index 1605273dd..3bc291659 100644 --- a/package.json +++ b/package.json @@ -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" @@ -188,4 +191,4 @@ "lint-staged": { "*.{js,css,md,vue}": "prettier --write" } -} \ No newline at end of file +} diff --git a/src/components/SettingsDialog/SettingsDialog.api.md b/src/components/SettingsDialog/SettingsDialog.api.md new file mode 100644 index 000000000..b9a0a3679 --- /dev/null +++ b/src/components/SettingsDialog/SettingsDialog.api.md @@ -0,0 +1,89 @@ + + +## API Reference + +### SettingsDialog + + + + + + + +### SettingsPanel + + + + + diff --git a/src/components/SettingsDialog/SettingsDialog.cy.ts b/src/components/SettingsDialog/SettingsDialog.cy.ts new file mode 100644 index 000000000..4f4b02441 --- /dev/null +++ b/src/components/SettingsDialog/SettingsDialog.cy.ts @@ -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) + }) +}) diff --git a/src/components/SettingsDialog/SettingsDialog.md b/src/components/SettingsDialog/SettingsDialog.md new file mode 100644 index 000000000..767128e44 --- /dev/null +++ b/src/components/SettingsDialog/SettingsDialog.md @@ -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`. + + + +## 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 }`. + + + +## 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`. + + + + diff --git a/src/components/SettingsDialog/SettingsDialog.vue b/src/components/SettingsDialog/SettingsDialog.vue new file mode 100644 index 000000000..90a338665 --- /dev/null +++ b/src/components/SettingsDialog/SettingsDialog.vue @@ -0,0 +1,49 @@ + + + diff --git a/src/components/SettingsDialog/SettingsPanel.cy.ts b/src/components/SettingsDialog/SettingsPanel.cy.ts new file mode 100644 index 000000000..6907badb8 --- /dev/null +++ b/src/components/SettingsDialog/SettingsPanel.cy.ts @@ -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') + }) +}) diff --git a/src/components/SettingsDialog/SettingsPanel.vue b/src/components/SettingsDialog/SettingsPanel.vue new file mode 100644 index 000000000..55721b520 --- /dev/null +++ b/src/components/SettingsDialog/SettingsPanel.vue @@ -0,0 +1,34 @@ + + + diff --git a/src/components/SettingsDialog/index.ts b/src/components/SettingsDialog/index.ts new file mode 100644 index 000000000..b4a598c32 --- /dev/null +++ b/src/components/SettingsDialog/index.ts @@ -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' diff --git a/src/components/SettingsDialog/stories/CustomTabContent.vue b/src/components/SettingsDialog/stories/CustomTabContent.vue new file mode 100644 index 000000000..c3430c595 --- /dev/null +++ b/src/components/SettingsDialog/stories/CustomTabContent.vue @@ -0,0 +1,34 @@ + + + diff --git a/src/components/SettingsDialog/stories/Default.vue b/src/components/SettingsDialog/stories/Default.vue new file mode 100644 index 000000000..18a841fb5 --- /dev/null +++ b/src/components/SettingsDialog/stories/Default.vue @@ -0,0 +1,29 @@ + + + diff --git a/src/components/SettingsDialog/stories/PanelBasic.vue b/src/components/SettingsDialog/stories/PanelBasic.vue new file mode 100644 index 000000000..2ed2f6b09 --- /dev/null +++ b/src/components/SettingsDialog/stories/PanelBasic.vue @@ -0,0 +1,20 @@ + + + diff --git a/src/components/SettingsDialog/stories/panels/GeneralPanel.vue b/src/components/SettingsDialog/stories/panels/GeneralPanel.vue new file mode 100644 index 000000000..0fc228774 --- /dev/null +++ b/src/components/SettingsDialog/stories/panels/GeneralPanel.vue @@ -0,0 +1,18 @@ + + + diff --git a/src/components/SettingsDialog/stories/panels/MembersPanel.vue b/src/components/SettingsDialog/stories/panels/MembersPanel.vue new file mode 100644 index 000000000..82f2cc183 --- /dev/null +++ b/src/components/SettingsDialog/stories/panels/MembersPanel.vue @@ -0,0 +1,15 @@ + + + diff --git a/src/components/SettingsDialog/stories/panels/ProfilePanel.vue b/src/components/SettingsDialog/stories/panels/ProfilePanel.vue new file mode 100644 index 000000000..ebdc8e261 --- /dev/null +++ b/src/components/SettingsDialog/stories/panels/ProfilePanel.vue @@ -0,0 +1,22 @@ + + + diff --git a/src/components/SettingsDialog/types.ts b/src/components/SettingsDialog/types.ts new file mode 100644 index 000000000..c32b2f50d --- /dev/null +++ b/src/components/SettingsDialog/types.ts @@ -0,0 +1,42 @@ +import type { Component } from 'vue' +import type { DialogSize } from '../Dialog/types' +import type { SidebarItemProps, SidebarSectionProps } from '../Sidebar/types' + +/** + * A single navigable settings tab shown in the dialog sidebar. Extends the + * sidebar item contract, adding an optional `component` rendered in the content + * area when the tab is active. + */ +export type SettingsTab = SidebarItemProps & { + /** Component rendered in the content area when this tab is active. */ + component?: Component +} + +/** A labelled group of settings tabs rendered as one sidebar section. */ +export type SettingsSection = Omit & { + items: SettingsTab[] +} + +export interface SettingsDialogProps { + /** + * Sidebar sections to render. Each item may carry a `component` that is + * shown in the content area when the item is selected. + */ + sections: SettingsSection[] + + /** Max-width size of the dialog. */ + size?: DialogSize +} + +export interface SettingsDialogEmits { + /** Fired when the dialog is opened or closed. */ + 'update:modelValue': [value: boolean] +} + +export interface SettingsPanelProps { + /** Heading rendered at the top of the panel. */ + title: string + + /** Optional sub-heading rendered below the title. */ + description?: string +} diff --git a/src/components/TextEditor/components/CodeBlockComponent.vue b/src/components/TextEditor/components/CodeBlockComponent.vue index ce57663cf..5714c1ac6 100644 --- a/src/components/TextEditor/components/CodeBlockComponent.vue +++ b/src/components/TextEditor/components/CodeBlockComponent.vue @@ -1,7 +1,7 @@