Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export interface TabsPlayground {
icons: Ref<boolean>
header: Ref<boolean>
teamDisabled: Ref<boolean>
mobileSelect: Ref<boolean>
tones: FormSelectOption[]
variants: FormSelectOption[]
modes: FormSelectOption[]
Expand All @@ -54,7 +55,7 @@ const tabsPlaygroundTemplate = html`<Flex direction="column">
<Badge tone="accent" variant="surface">Live playground</Badge>
</Flex>
<Tabs id="tabs-playground-preview" group="tabs-playground-views" :selectedTab="selected"
:tone="tone" :variant="shell" :tabVariant="controls" :variantMode="mode" ariaLabel="Project workspace">
:tone="tone" :variant="shell" :tabVariant="controls" :variantMode="mode" :mobileSelect="mobileSelect" ariaLabel="Project workspace">
<template #header>
<Flex r-if="header" class="tabs__header" justify="between" align="center" wrap="true">
<div><p class="text-eyebrow m-0">PureStack Studio</p><h3 class="mt-1 mb-0">Project workspace</h3></div>
Expand Down Expand Up @@ -94,6 +95,7 @@ const tabsPlaygroundTemplate = html`<Flex direction="column">
<FormCheck id="tabs-disabled" label="Disable Team" :checked="teamDisabled"/>
</Flex>
</div>
<FormCheck id="tabs-mobile-select" label="Use a select on mobile" :checked="mobileSelect"/>
<Flex justify="between" align="center" wrap="true">
<p class="text-muted m-0" role="status">Selected: <code id="tabs-selected">{{ selected }}</code></p>
<Btn variant="outline" size="sm" @click="reset">Reset playground</Btn>
Expand All @@ -115,6 +117,7 @@ const tabsPlayground = defineComponent<TabsPlayground>(tabsPlaygroundTemplate, {
const icons = ref(true)
const header = ref(true)
const teamDisabled = ref(true)
const mobileSelect = ref(false)
const refresh = () => {
if (teamDisabled() && selected() === 'tabs-lab-team')
selected('tabs-lab-overview')
Expand All @@ -132,6 +135,7 @@ const tabsPlayground = defineComponent<TabsPlayground>(tabsPlaygroundTemplate, {
icons,
header,
teamDisabled,
mobileSelect,
refresh,
tones: [
'neutral',
Expand Down Expand Up @@ -180,6 +184,7 @@ const tabsPlayground = defineComponent<TabsPlayground>(tabsPlaygroundTemplate, {
Tabs: {
group: 'tabs-playground-views',
selectedTab: selected(),
mobileSelect: mobileSelect(),
tone: tone(),
variant: shell(),
tabVariant: controls(),
Expand All @@ -204,6 +209,7 @@ const tabsPlayground = defineComponent<TabsPlayground>(tabsPlaygroundTemplate, {
icons(true)
header(true)
teamDisabled(true)
mobileSelect(false)
refresh()
},
}
Expand Down
31 changes: 25 additions & 6 deletions frontend/purestack.studio/components/layout/tabs/tabs.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ nav:
<Grid columns="1" columnsMd="3">
<Panel variant="outline" bodyClass="p-3"><p class="text-eyebrow mt-0">Selection</p><strong>One shared ref</strong><p class="text-muted mb-0">Connect the active pane to forms, buttons and the rest of your app.</p></Panel>
<Panel variant="outline" bodyClass="p-3"><p class="text-eyebrow mt-0">State</p><strong>Panels stay mounted</strong><p class="text-muted mb-0">Switch views without discarding a draft or resetting an input.</p></Panel>
<Panel variant="outline" bodyClass="p-3"><p class="text-eyebrow mt-0">Space</p><strong>Controls adapt</strong><p class="text-muted mb-0">Overflow on narrow containers. A native selector on mobile.</p></Panel>
<Panel variant="outline" bodyClass="p-3"><p class="text-eyebrow mt-0">Space</p><strong>Controls adapt</strong><p class="text-muted mb-0">Overflow on narrow containers. An optional native selector on mobile.</p></Panel>
</Grid>

## In context
Expand Down Expand Up @@ -127,7 +127,7 @@ const select = (id: string) => {
}
```

`refresh()` rebuilds the generated buttons and selector; it keeps the existing panel content. Scope it to the root you changed. Also refresh after inserting or removing panes. A ref assignment updates the underlying radio binding, but the current runtime does not watch every component prop automatically.
`refresh()` rebuilds the generated buttons and selector; it keeps the existing panel content. Scope it to the root you changed. Also refresh after inserting or removing panes. A ref assignment updates the underlying radio binding, but the current runtime does not watch every component prop automatically. Changes to `mobileSelect` update the layout automatically and do not require a refresh.

Choose an enabled pane explicitly when disabling or removing the selected pane. This keeps the model and visible view aligned. The playground demonstrates that policy in its `refresh` action.

Expand Down Expand Up @@ -157,9 +157,23 @@ Change the example's width without resizing the page. On desktop, the More tabs
| --- | --- | --- |
| Wide viewport, enough container space | Horizontal buttons | Choose Full available width |
| Wide viewport, narrow container | Buttons plus More tabs | Choose 280px or 360px |
| Viewport at or below the framework's `sm` breakpoint (640px) | Native select | Open the page on a phone or resize the browser |
| Viewport at or below the framework's `sm` breakpoint (640px), with `mobileSelect` enabled | Native select | Enable Use a select on mobile in the playground, then resize the browser |
| Mobile viewport with `mobileSelect` disabled (default) | Buttons plus More tabs when needed | Leave Use a select on mobile unchecked in the playground |

Container width determines overflow. **Viewport width** determines compact mode. A 280px component inside a desktop page still uses the overflow menu. Selection and panel state survive either transition.
Container width determines overflow. When `mobileSelect` is enabled, **viewport width** determines compact mode. A 280px component inside a desktop page still uses the overflow menu. Selection and panel state survive either transition.

### Use a select on mobile

Tabs keep their buttons and overflow menu at every viewport width by default. Set `:mobileSelect="true"` on an individual Tabs to use a native select on mobile:

```html
<Tabs :mobileSelect="true" selectedTab="mobile-overview">
<TabPane id="mobile-overview" label="Overview">Overview content</TabPane>
<TabPane id="mobile-details" label="Details">Details content</TabPane>
</Tabs>
```

The default is `false`. In a Regor app, bind a boolean ref with `:mobileSelect="mobileSelect"`; changing it switches between the mobile select and tab buttons without rebuilding the component or resetting its selected pane. The playground's Use a select on mobile checkbox demonstrates this.

## Header slots, overrides and nested groups

Expand Down Expand Up @@ -206,7 +220,7 @@ All variant values are available in the playground: `none`, `solid`, `surface`,
</div>

<nav class="api-index" aria-label="Tabs API">
<a href="#tabs-group-api">group</a><a href="#tabs-selected-api">selectedTab</a><a href="#tabs-label-api">ariaLabel</a><a href="#tabs-tone-api">tone</a><a href="#tabs-variant-api">variants</a><a href="#pane-id-api">pane identity</a><a href="#pane-disabled-api">disabled</a><a href="#pane-style-api">pane styling</a><a href="#tabs-slots-api">slots</a><a href="#tabs-runtime-api">runtime</a>
<a href="#tabs-group-api">group</a><a href="#tabs-selected-api">selectedTab</a><a href="#tabs-mobile-select-api">mobileSelect</a><a href="#tabs-label-api">ariaLabel</a><a href="#tabs-tone-api">tone</a><a href="#tabs-variant-api">variants</a><a href="#pane-id-api">pane identity</a><a href="#pane-disabled-api">disabled</a><a href="#pane-style-api">pane styling</a><a href="#tabs-slots-api">slots</a><a href="#tabs-runtime-api">runtime</a>
</nav>

### Tabs properties
Expand All @@ -220,6 +234,11 @@ All variant values are available in the playground: `none`, `solid`, `surface`,
<p>Set an enabled pane explicitly for predictable initial state. Without a valid checked pane, the enhanced runtime chooses the first enabled pane; the CSS-only fallback displays the first pane.</p>
</ApiProperty>

<ApiProperty anchor="tabs-mobile-select-api" name="mobileSelect" valueType="RefOrValue&lt;boolean&gt;" defaultValue="false">
<p>Set true to use a native select at viewport widths up to 640px. By default, Tabs retain their buttons and overflow menu on mobile.</p>
<p>Bind a boolean ref to change this per instance. The browser runtime updates the layout automatically and preserves the selected pane; no refresh is needed for this prop.</p>
</ApiProperty>

<ApiProperty anchor="tabs-label-api" name="ariaLabel" valueType="RefOrValue&lt;string&gt;" defaultValue="Tabs">
<p>Accessible group name for the tab list and compact selector. Describe the views, such as Release workspace or Deployment environment.</p>
</ApiProperty>
Expand Down Expand Up @@ -484,7 +503,7 @@ A text label, an optional icon, a disabled view, and label fallback to the ID ar
| Selecting one group clears another | Give each Tabs instance a different group |
| The wrong panel opens or labels target another example | Make every TabPane id document-unique |
| The model changes but enhanced controls look stale | Refresh the affected root after the DOM update |
| A narrow desktop demo does not use a select | Compact mode follows viewport width, not container width |
| A narrow desktop demo does not use a select | Enable mobileSelect; compact mode follows viewport width, not container width |
| A header appears below the generated controls | Add tabs__header to the header-slot wrapper |
| A selected pane becomes disabled | Move selection to an enabled pane before refreshing |

Expand Down
79 changes: 79 additions & 0 deletions packages/ts-components/src/standard/tabs/tabs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,85 @@ import { defineIconComponents } from '../icon/icon'
import { defineTabsComponents } from './tabs'

describe('Tabs rendering', () => {
it.each([
[undefined, null],
[true, 'true'],
[false, 'false'],
])(
'renders the mobile select setting %s as %s',
async (mobileSelect, expected) => {
const cleanup = ensureDomGlobals()
try {
const binding =
mobileSelect === undefined ? '' : `:mobileSelect="${mobileSelect}"`
const html = await renderApp(
`<Tabs ${binding}><TabPane id="overview" label="Overview">Content</TabPane></Tabs>`,
{
components: {
...defineIconComponents(getSvgIcon),
...defineTabsComponents(),
},
context: createTestContext({
pageInfo: { relPath: 'tabs.mdx', urlPath: '/tabs/' },
}),
},
)
if (expected === null) {
expect(html).not.toContain('data-mobile-select=')
} else {
expect(html).toContain(`data-mobile-select="${expected}"`)
}
} finally {
cleanup()
}
},
)

it('updates mobileSelect reactively per instance without changing selection', () => {
const cleanupDom = createDom(
'<html><body><div id="app"></div></body></html>',
)
const mobileSelect = ref(false)
const selectedTab = ref('details')
const app = createApp(
{
components: {
...defineIconComponents(getSvgIcon),
...defineTabsComponents(),
},
mobileSelect,
selectedTab,
},
{
selector: '#app',
template: `<Tabs id="configurable-tabs" group="configurable" :mobileSelect="mobileSelect" :selectedTab="selectedTab">
<TabPane id="overview" label="Overview">Overview</TabPane>
<TabPane id="details" label="Details">Details</TabPane>
</Tabs>
<Tabs id="default-tabs" group="default"><TabPane id="other" label="Other">Other</TabPane></Tabs>`,
},
)
try {
const root = document.querySelector('#configurable-tabs')
const other = document.querySelector('#default-tabs')
expect(root?.getAttribute('data-mobile-select')).toBe('false')
expect(other?.getAttribute('data-mobile-select')).toBeNull()

mobileSelect(true)
expect(root?.getAttribute('data-mobile-select')).toBe('true')
mobileSelect(false)
expect(root?.getAttribute('data-mobile-select')).toBe('false')
expect(other?.getAttribute('data-mobile-select')).toBeNull()
expect(selectedTab()).toBe('details')
expect(
document.querySelector<HTMLInputElement>('#details')?.checked,
).toBe(true)
} finally {
app.unbind()
cleanupDom()
}
})

it('renders slotted tab content and active tab state', async () => {
const cleanup = ensureDomGlobals()
const components = {
Expand Down
4 changes: 3 additions & 1 deletion packages/ts-components/src/standard/tabs/tabs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export class Tabs {
group: string = defaultGroup
ariaLabel?: RefOrValue<string>
selectedTab?: RefOrValue<string>
mobileSelect?: RefOrValue<boolean>
tone?: RefOrValue<SemanticTone>
variant?: RefOrValue<ComponentVariant>
variantMode?: RefOrValue<ComponentVariantMode>
Expand All @@ -48,7 +49,7 @@ export interface TabPane {
tabClasses?: ComputedRef<string>
}

const tabsTemplate = html`<section class="tabs" :class="classes">
const tabsTemplate = html`<section class="tabs" :class="classes" :data-mobile-select="mobileSelect">
<slot name="header"></slot>
<div class="tabs__list" role="tablist" :aria-label="ariaLabel || 'Tabs'">
<slot></slot>
Expand Down Expand Up @@ -96,6 +97,7 @@ function defineTabsComponent() {
'ariaLabel',
'group',
'selectedTab',
'mobileSelect',
'tone',
'variant',
'variantMode',
Expand Down
74 changes: 74 additions & 0 deletions packages/ts-components/src/standard/tabs/tabsStyle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { createDom } from '@purestack/ts-minidom'
import { styleBuilder } from '@purestack/ts-style'
import { beforeEach, describe, expect, it } from 'vitest'
import { registerTabsStyles } from './tabsStyle'

function tabsOwnerId(control: Element) {
let parent = control.parentElement
while (parent) {
if (parent.classList.contains('tabs')) return parent.id
parent = parent.parentElement
}
}

describe('nested Tabs responsive styles', () => {
beforeEach(() => {
styleBuilder.reset()
})

it.each(['light', 'dark'] as const)(
'keeps a compact parent from changing its nested controls in the %s theme',
(theme) => {
const cleanup = createDom(`<html><body>
<section id="outer" class="tabs tabs--enhanced tabs--compact">
<div class="tabs__tabs-row"></div>
<div class="tabs__select-wrap"></div>
<div class="tabs__list"><div class="tabs__item">
<label class="tabs__tab">Outer tab</label>
<section class="tabs__panel">
<section id="inner" class="tabs tabs--enhanced" data-mobile-select="false">
<div class="tabs__tabs-row"></div>
<div class="tabs__select-wrap"></div>
<div class="tabs__list"><div class="tabs__item">
<label class="tabs__tab">Inner tab</label>
</div></div>
</section>
<section id="static" class="tabs">
<div class="tabs__list"><div class="tabs__item">
<label class="tabs__tab">Static fallback tab</label>
</div></div>
</section>
</section>
</div></div>
</section>
</body></html>`)
try {
registerTabsStyles()
const rules = [...styleBuilder.get(theme).children.values()]
const compactRules = rules.filter((rule) =>
rule.selector.includes('.tabs--compact'),
)
expect(compactRules.length).toBeGreaterThan(0)
for (const rule of compactRules) {
const matched = [...document.querySelectorAll(rule.selector)]
expect(matched.length).toBeGreaterThan(0)
for (const control of matched) {
expect(tabsOwnerId(control)).toBe('outer')
}
}

const labelRule = rules.find(
(rule) =>
rule.selector.includes('.tabs--enhanced') &&
rule.selector.endsWith('.tabs__tab'),
)
expect(labelRule).toBeDefined()
const labels = [...document.querySelectorAll(labelRule?.selector ?? '')]
expect(labels).toHaveLength(2)
expect(labels.map(tabsOwnerId)).toEqual(['outer', 'inner'])
} finally {
cleanup()
}
},
)
})
14 changes: 9 additions & 5 deletions packages/ts-components/src/standard/tabs/tabsStyle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,17 +215,21 @@ function registerTabsPanelStyles(theme: ThemeMode) {
}

function registerTabsResponsiveStyles(theme: ThemeMode) {
styleBuilder.select('.tabs--enhanced .tabs__tabs-row', theme).display('flex')
styleBuilder
.select('.tabs--enhanced > .tabs__tabs-row', theme)
.display('flex')

styleBuilder
.select('.tabs--compact .tabs__select-wrap', theme)
.select('.tabs--compact > .tabs__select-wrap', theme)
.display('block')

styleBuilder.select('.tabs--enhanced .tabs__tab', theme).display('none')
styleBuilder
.select('.tabs--enhanced > .tabs__list > .tabs__item > .tabs__tab', theme)
.display('none')

styleBuilder.select('.tabs--compact .tabs__tabs-row', theme).display('none')
styleBuilder.select('.tabs--compact > .tabs__tabs-row', theme).display('none')

styleBuilder
.select('.tabs--compact .tabs__list', theme)
.select('.tabs--compact > .tabs__list', theme)
.gridTemplateColumns('1fr')
}
Loading
Loading