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
24 changes: 24 additions & 0 deletions .changeset/storybook-sync-coverage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
"@repo/storybook": patch
---

Cover the components and props synced from cloud-portal in `@datum-cloud/datum-ui@0.7`.

**Added stories**

- `base/mobile-sheet` — Default, WithDescription, NoFooter
- `base/responsive-dropdown` — Default menu, WithCustomContent. Children use plain buttons because Radix `DropdownMenuItem` requires a `DropdownMenu` context that only exists on the desktop branch of `ResponsiveDropdown`.
- `features/rich-text-editor` — Editor + ReadOnly (`RichTextContent`)
- `features/form-dialog` — `showHeaderClose` toggle
- `base/responsive-popover` — Default, ResponsiveFalse
- `features/multi-select` — Default, WithPresetSelection

**Enhanced stories**

- `features/tag-input` — new variants covering `delimiters`, `normalizer`, `validator`, and auto-confirm-on-blur
- `features/page-title` — `LongDescription` variant showing the new `max-w-2xl` constraint
- `base/tooltip` — docs note describing the new mobile long-press behavior

**Styling**

- `stories/storybook.css` opts into `@datum-cloud/datum-ui/styles/canela` so the `font-title` utility renders Canela instead of the system-sans fallback.
48 changes: 48 additions & 0 deletions .changeset/sync-cloud-portal-0.7.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
---
"@datum-cloud/datum-ui": minor
---

Sync behavior and primitives that landed in cloud-portal's vendored `app/modules/datum-ui/` fork between March and April 2026.

**Added**

- `useBreakpoint` hook returning `'mobile' | 'tablet' | 'desktop'` tier (SSR-safe, matchMedia-backed).
- `MobileSheet` base primitive — bottom-sheet wrapper around `Sheet` for mobile UX.
- `ResponsiveDropdown` base primitive — `DropdownMenu` on desktop/tablet, `MobileSheet` on mobile.
- `RichTextEditor` feature component (compound API: `RichTextEditor.Toolbar`, `.Bold`, `.Italic`, `.Underline`, `.Strike`, `.Link`, `.Content`, `.CharacterCount`) with TipTap 3.x as optional peer deps.
- `RichTextContent` read-only renderer with DOMPurify sanitization.
- Optional Canela font export at `@datum-cloud/datum-ui/styles/canela` (opt-in; default stack is system sans).
- Mobile long-press tooltip support via `TouchTooltipBubble` (500ms press → show, 1500ms auto-dismiss).
- `ResponsivePopover` base primitive — `Popover` on desktop/tablet, `MobileSheet` on mobile. Honors `InSheetContext` to avoid sheet-in-sheet stacking.
- `InSheetContext` / `useInSheet` helper exported from `@datum-cloud/datum-ui/mobile-sheet` — lets nested responsive components detect when they're already inside a sheet and stay as popovers.
- `MultiSelect` feature component (ported from cloud-portal) with responsive overlay support.

**Changed**

- `PageTitle` — title now uses `font-title text-3xl leading-none`, description gains `max-w-2xl`, title span exposes `data-e2e="page-title"`.
- `TagsInput` — new optional props `delimiters`, `normalizer`, `validator` (Zod); auto-confirms pending input on blur. Defaults preserve existing behavior.
- `Form.Dialog` — new optional `showHeaderClose` prop (default `true`); passing `false` disables the header close (X).
- `Form.Field` — label row now uses `items-start` (visual alignment change — multi-line labels now top-align with help icon). New optional `showErrors` prop (default `true`). Help-icon tooltip now wraps responsively on narrow viewports (`w-[calc(100vw-2rem)] sm:w-auto sm:max-w-xs`).
- `TimeRangePicker` — renders inside `MobileSheet` on mobile viewports; existing Popover behavior preserved on tablet/desktop.
- `Tooltip` — `TooltipContent` gains `max-w-[calc(100vw-2rem)]` so narrow viewports don't clip long messages.
- `TaskQueueDropdown` now uses `ResponsiveDropdown` — renders as a bottom sheet on mobile with a "Tasks" title; keeps the desktop dropdown layout (384px fixed width) on ≥768px viewports. `TaskPanelHeader` is hidden on mobile. Upstream's `summaryRenderContent` feature is preserved.
- `ResponsiveDropdown` gains optional `responsive?: boolean` prop (default `true`) and honors `InSheetContext` — nested instances inside a `MobileSheet` automatically stay as dropdowns to prevent sheet-in-sheet stacking.
- Popover/dropdown consumers are now responsive by default. Each gains optional `responsive?: boolean` (default `true`) and `sheetTitle?: string` props. Desktop behavior unchanged. Affected: `Combobox`, `Autocomplete`, `Autosearch`, `CalendarDatePicker`, `DateTimePicker`, `MoreActions`, `RichTextEditor` link toolbar, `AbsoluteRangePanel` (TimeRangePicker internal, context-suppressed), `DataTable` row actions and checkbox/select filter popovers.

- Option-picker components (`Autocomplete`, `MultiSelect`, `Autosearch`, `CheckboxFilter`) now share an internal engine (`useOptionPicker` + `OptionList`) that owns search filtering, keyboard navigation, empty/creatable rows, and virtualization. No public API changes — the refactor is internal and behavior-preserving.

- `CalendarDatePicker` internally decomposed into 5 focused files (types, hook, header, presets, trigger + composition root). No public API changes — 901 lines → 170-line composition root.
- `MoreActions` action shape renamed: `action` property → `onClick` for consistency with `DataTable`'s `ActionItem<TData>`. Shared `ActionRow` component extracted; consumed by both `MoreActions` and `DataTable` row actions.

- `CheckboxFilter` (data-table) is now a thin adapter over `MultiSelect`. Visually matches MultiSelect's badge trigger. No public API change to `FilterCheckboxProps`.
- `CalendarDatePicker` and `DateTimePicker` share a `useDateConstraints` hook for minDate/maxDate/disablePast/disableFuture constraint derivation (internal, no API change). `DateTimePicker` gains optional `disablePast`/`disableFuture` props.
- `TimePicker` upgraded from native `<input type="time">` to a scrollable time-slot dropdown (ResponsivePopover + OptionList). Breaking: `step` prop is now in minutes (default 15); native mobile time picker replaced by MobileSheet with time slots.

**Removed**

- `Combobox` is removed. Migrate to `Autocomplete` — the APIs overlap for the common case (`options`, `value`, `onValueChange`, `placeholder`, `disabled`). `Autocomplete` additionally supports option grouping, virtualization, custom rendering, and creatable items. `Form.Combobox` continues to work (now wraps `Autocomplete` internally).

**Dependencies**

- New optional peer deps: `@tiptap/react`, `@tiptap/starter-kit`, `@tiptap/extension-link`, `@tiptap/extension-underline`, `@tiptap/extension-character-count`, `@tiptap/extension-placeholder` (all `>=3`, required only if consuming `RichTextEditor`).
- New runtime dep: `isomorphic-dompurify` (required by `RichTextContent` sanitization).
117 changes: 117 additions & 0 deletions apps/storybook/stories/base/mobile-sheet.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import type { Meta, StoryObj } from 'storybook-react-rsbuild'
import { Button } from '@datum-cloud/datum-ui/button'
import { MobileSheet } from '@datum-cloud/datum-ui/mobile-sheet'
import { useState } from 'react'

/**
* MobileSheet is designed for narrow (mobile) viewports.
* In Storybook it renders at all widths since MobileSheet is viewport-agnostic —
* the consumer decides when to show it (e.g. via a breakpoint check).
*/
const meta: Meta<typeof MobileSheet> = {
title: 'Base/MobileSheet',
component: MobileSheet,
argTypes: {
title: { control: 'text' },
description: { control: 'text' },
},
args: {
title: 'Options',
description: undefined,
},
}

export default meta

type Story = StoryObj<typeof MobileSheet>

export const Default: Story = {
render: (args) => {
const [open, setOpen] = useState(false)
return (
<>
<Button type="primary" onClick={() => setOpen(true)}>
Open Sheet
</Button>
<MobileSheet
{...args}
open={open}
onOpenChange={setOpen}
footer={(
<div className="flex justify-end gap-2">
<Button type="secondary" theme="outline" onClick={() => setOpen(false)}>
Close
</Button>
</div>
)}
>
<div className="space-y-2 px-4 py-3">
<p className="text-sm">This is the body content of the mobile sheet.</p>
<p className="text-muted-foreground text-xs">
Scroll-overflow is handled automatically when content is tall.
</p>
</div>
</MobileSheet>
</>
)
},
}

export const WithDescription: Story = {
args: {
title: 'Sort & Filter',
description: 'Adjust how results are displayed.',
},
render: (args) => {
const [open, setOpen] = useState(false)
return (
<>
<Button type="secondary" theme="outline" onClick={() => setOpen(true)}>
Open with Description
</Button>
<MobileSheet
{...args}
open={open}
onOpenChange={setOpen}
footer={(
<div className="flex justify-end gap-2">
<Button type="secondary" theme="outline" onClick={() => setOpen(false)}>
Close
</Button>
<Button type="primary" onClick={() => setOpen(false)}>
Apply
</Button>
</div>
)}
>
<div className="px-4 py-3">
<p className="text-sm">Filter options would appear here.</p>
</div>
</MobileSheet>
</>
)
},
}

export const NoFooter: Story = {
args: {
title: 'Information',
},
render: (args) => {
const [open, setOpen] = useState(false)
return (
<>
<Button type="secondary" theme="outline" onClick={() => setOpen(true)}>
Open (No Footer)
</Button>
<MobileSheet {...args} open={open} onOpenChange={setOpen}>
<div className="px-4 py-3">
<p className="text-sm">
This sheet has no footer. The user dismisses it by swiping or tapping outside.
</p>
</div>
</MobileSheet>
</>
)
},
}
144 changes: 144 additions & 0 deletions apps/storybook/stories/base/responsive-dropdown.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import type { Meta, StoryObj } from 'storybook-react-rsbuild'
import { Button } from '@datum-cloud/datum-ui/button'
import { ResponsiveDropdown } from '@datum-cloud/datum-ui/responsive-dropdown'
import { CreditCardIcon, LogOutIcon, SettingsIcon } from 'lucide-react'
import { useState } from 'react'

/**
* ResponsiveDropdown renders as a Dropdown at ≥768px and as a MobileSheet at <768px.
* Resize the Storybook viewport to see the adaptive behaviour.
*
* IMPORTANT: children must render in BOTH contexts. Radix `DropdownMenuItem` /
* `DropdownMenuLabel` require a `DropdownMenu` context that only exists on the
* desktop branch, so they throw on mobile. Use plain buttons, links, or custom
* rows that work in either environment.
*/
const meta: Meta<typeof ResponsiveDropdown> = {
title: 'Base/ResponsiveDropdown',
component: ResponsiveDropdown,
parameters: {
docs: {
description: {
component:
'Renders as a dropdown menu at ≥768px and as a mobile bottom sheet at <768px. The breakpoint is detected via `useBreakpoint`. Resize the viewport to observe the switch.\n\n**Children restriction:** on the mobile branch the children are placed directly inside a `MobileSheet` with no Radix `DropdownMenu` context. Avoid `DropdownMenuItem` / `DropdownMenuLabel` inside `ResponsiveDropdown` — use plain buttons or links instead.',
},
},
},
}

export default meta

type Story = StoryObj<typeof ResponsiveDropdown>

function MenuRow({
icon,
label,
variant = 'default',
onSelect,
}: {
icon: React.ReactNode
label: string
variant?: 'default' | 'destructive'
onSelect: () => void
}) {
return (
<button
type="button"
onClick={onSelect}
className={`hover:bg-accent hover:text-accent-foreground flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm ${
variant === 'destructive' ? 'text-destructive' : ''
}`}
>
{icon}
<span>{label}</span>
</button>
)
}

export const Default: Story = {
render: () => {
const [open, setOpen] = useState(false)
return (
<div className="flex items-center justify-center p-10">
<ResponsiveDropdown
open={open}
onOpenChange={setOpen}
sheetTitle="Account"
trigger={(
<Button type="secondary" theme="outline">
My Account
</Button>
)}
>
<div className="flex flex-col p-1">
<div className="text-muted-foreground px-3 py-2 text-xs font-semibold">
My Account
</div>
<div className="bg-border -mx-1 my-1 h-px" />
<MenuRow
icon={<CreditCardIcon size={14} />}
label="Billing"
onSelect={() => setOpen(false)}
/>
<MenuRow
icon={<SettingsIcon size={14} />}
label="Settings"
onSelect={() => setOpen(false)}
/>
<div className="bg-border -mx-1 my-1 h-px" />
<MenuRow
icon={<LogOutIcon size={14} />}
label="Log out"
variant="destructive"
onSelect={() => setOpen(false)}
/>
</div>
</ResponsiveDropdown>
</div>
)
},
}

export const WithCustomContent: Story = {
name: 'WithCustomContent (rich panel)',
render: () => {
const [open, setOpen] = useState(false)
return (
<div className="flex items-center justify-center p-10">
<ResponsiveDropdown
open={open}
onOpenChange={setOpen}
sheetTitle="Filters"
sheetDescription="Refine the visible results"
contentClassName="w-80 rounded-xl p-4"
trigger={(
<Button type="secondary" theme="outline">
Open filters
</Button>
)}
>
<div className="space-y-4 p-4 sm:p-0">
<div className="space-y-1">
<h3 className="text-sm font-semibold">Filter by status</h3>
<p className="text-muted-foreground text-xs">
Arbitrary content is allowed here — the component just hands your
children to a Popover on desktop or a bottom sheet on mobile.
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button type="secondary" theme="outline" size="small">
Active
</Button>
<Button type="secondary" theme="outline" size="small">
Pending
</Button>
<Button type="secondary" theme="outline" size="small">
Archived
</Button>
</div>
</div>
</ResponsiveDropdown>
</div>
)
},
}
Loading
Loading