@flamingo-stack/openframe-frontend-core 0.0.399 → 0.0.400-snapshot.20260710144905

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/dist/{chunk-XBCAY6NE.js → chunk-2B5FIJE5.js} +3 -3
  2. package/dist/{chunk-JBLBFJ4N.cjs → chunk-3GXZPZHR.cjs} +32 -32
  3. package/dist/{chunk-JBLBFJ4N.cjs.map → chunk-3GXZPZHR.cjs.map} +1 -1
  4. package/dist/{chunk-BSJO5VFV.js → chunk-5P5WMTVW.js} +5 -5
  5. package/dist/{chunk-G5NDKTX6.cjs → chunk-7YKHVAUU.cjs} +40 -40
  6. package/dist/{chunk-G5NDKTX6.cjs.map → chunk-7YKHVAUU.cjs.map} +1 -1
  7. package/dist/{chunk-CRRH7RJX.js → chunk-B3IDZ6JJ.js} +5 -5
  8. package/dist/{chunk-ZDT2MBKE.cjs → chunk-BI23LOMC.cjs} +40 -40
  9. package/dist/{chunk-ZDT2MBKE.cjs.map → chunk-BI23LOMC.cjs.map} +1 -1
  10. package/dist/{chunk-NCW22SII.cjs → chunk-CG7LIPSA.cjs} +73 -73
  11. package/dist/{chunk-NCW22SII.cjs.map → chunk-CG7LIPSA.cjs.map} +1 -1
  12. package/dist/{chunk-ZSZRJWY3.js → chunk-DH6HTYOV.js} +153 -134
  13. package/dist/chunk-DH6HTYOV.js.map +1 -0
  14. package/dist/{chunk-2XVOYDRF.js → chunk-EDR55RQJ.js} +4763 -4735
  15. package/dist/chunk-EDR55RQJ.js.map +1 -0
  16. package/dist/{chunk-7OKNKZBW.cjs → chunk-F2LZ67KM.cjs} +1250 -1222
  17. package/dist/chunk-F2LZ67KM.cjs.map +1 -0
  18. package/dist/{chunk-Z5ERSGP2.js → chunk-FLDYBM25.js} +2 -2
  19. package/dist/{chunk-QNYNXSYQ.cjs → chunk-FRLLKYMO.cjs} +7 -7
  20. package/dist/{chunk-QNYNXSYQ.cjs.map → chunk-FRLLKYMO.cjs.map} +1 -1
  21. package/dist/{chunk-JWBRDNYH.cjs → chunk-J4XR6YYZ.cjs} +23 -4
  22. package/dist/chunk-J4XR6YYZ.cjs.map +1 -0
  23. package/dist/{chunk-4KWIQEZT.js → chunk-MTBFE3JF.js} +2 -2
  24. package/dist/{chunk-VVKWNVKE.js → chunk-MZPEN27A.js} +6 -6
  25. package/dist/{chunk-RIC4TCPD.cjs → chunk-PLB6Z3E4.cjs} +19 -19
  26. package/dist/{chunk-RIC4TCPD.cjs.map → chunk-PLB6Z3E4.cjs.map} +1 -1
  27. package/dist/{chunk-G3A7QHCX.js → chunk-PMR74LEE.js} +2 -2
  28. package/dist/{chunk-WBLRUO3F.cjs → chunk-Q3AO2QWK.cjs} +3 -3
  29. package/dist/{chunk-WBLRUO3F.cjs.map → chunk-Q3AO2QWK.cjs.map} +1 -1
  30. package/dist/{chunk-VOHOA3WV.cjs → chunk-Q4HI3DGY.cjs} +15 -15
  31. package/dist/{chunk-VOHOA3WV.cjs.map → chunk-Q4HI3DGY.cjs.map} +1 -1
  32. package/dist/{chunk-D5IHYUBY.cjs → chunk-QDERWRT4.cjs} +29 -29
  33. package/dist/{chunk-D5IHYUBY.cjs.map → chunk-QDERWRT4.cjs.map} +1 -1
  34. package/dist/{chunk-YWCCKB4J.cjs → chunk-QOOWYEPL.cjs} +5 -5
  35. package/dist/{chunk-YWCCKB4J.cjs.map → chunk-QOOWYEPL.cjs.map} +1 -1
  36. package/dist/{chunk-4K5XJ5EJ.js → chunk-RWKVTABY.js} +3 -3
  37. package/dist/{chunk-O5NA3ULU.cjs → chunk-TGWHUEPD.cjs} +73 -73
  38. package/dist/{chunk-O5NA3ULU.cjs.map → chunk-TGWHUEPD.cjs.map} +1 -1
  39. package/dist/{chunk-FXWURWDU.js → chunk-TZDROKLI.js} +3 -3
  40. package/dist/{chunk-4H2ICJYB.js → chunk-V3NY65ET.js} +4 -4
  41. package/dist/{chunk-A6KGHQRF.js → chunk-V4YFWSB2.js} +6 -6
  42. package/dist/{chunk-4PEC5KU6.cjs → chunk-VIEWUAFE.cjs} +12 -12
  43. package/dist/{chunk-4PEC5KU6.cjs.map → chunk-VIEWUAFE.cjs.map} +1 -1
  44. package/dist/{chunk-2L6ALD2Q.js → chunk-YON2QYWK.js} +2 -2
  45. package/dist/components/case-studies/index.cjs +9 -9
  46. package/dist/components/case-studies/index.js +3 -3
  47. package/dist/components/chat/chat-ticket-list.d.ts.map +1 -1
  48. package/dist/components/chat/guide-welcome.d.ts.map +1 -1
  49. package/dist/components/chat/index.cjs +3 -3
  50. package/dist/components/chat/index.js +2 -2
  51. package/dist/components/chat/mingo-chat-history.d.ts.map +1 -1
  52. package/dist/components/chat/mingo-welcome.d.ts.map +1 -1
  53. package/dist/components/contact/index.cjs +4 -4
  54. package/dist/components/contact/index.js +3 -3
  55. package/dist/components/docs/index.cjs +6 -6
  56. package/dist/components/docs/index.js +5 -5
  57. package/dist/components/embeds/index.cjs +4 -4
  58. package/dist/components/embeds/index.js +3 -3
  59. package/dist/components/faq/index.cjs +5 -5
  60. package/dist/components/faq/index.js +4 -4
  61. package/dist/components/features/auth/auth-benefits-panel.d.ts.map +1 -1
  62. package/dist/components/features/auth/complete-account-form.d.ts.map +1 -1
  63. package/dist/components/features/auth/create-organization-form.d.ts.map +1 -1
  64. package/dist/components/features/auth/login-form.d.ts.map +1 -1
  65. package/dist/components/features/auth/openframe-sso-login-form.d.ts.map +1 -1
  66. package/dist/components/features/auth/openframe-sso-signup-form.d.ts.map +1 -1
  67. package/dist/components/features/auth/password-reset-form.d.ts.map +1 -1
  68. package/dist/components/features/index.cjs +3 -3
  69. package/dist/components/features/index.js +2 -2
  70. package/dist/components/help-center-pages/index.cjs +22 -22
  71. package/dist/components/help-center-pages/index.js +13 -13
  72. package/dist/components/index.cjs +131 -125
  73. package/dist/components/index.cjs.map +1 -1
  74. package/dist/components/index.js +17 -11
  75. package/dist/components/index.js.map +1 -1
  76. package/dist/components/navigation/index.cjs +3 -3
  77. package/dist/components/navigation/index.js +2 -2
  78. package/dist/components/onboarding-guides/index.cjs +7 -7
  79. package/dist/components/onboarding-guides/index.js +6 -6
  80. package/dist/components/related-content/index.cjs +5 -5
  81. package/dist/components/related-content/index.js +4 -4
  82. package/dist/components/tickets/index.cjs +6 -6
  83. package/dist/components/tickets/index.js +5 -5
  84. package/dist/components/ui/data-table/data-table-header.d.ts.map +1 -1
  85. package/dist/components/ui/data-table/types.d.ts +6 -0
  86. package/dist/components/ui/data-table/types.d.ts.map +1 -1
  87. package/dist/components/ui/date-picker.d.ts +41 -4
  88. package/dist/components/ui/date-picker.d.ts.map +1 -1
  89. package/dist/components/ui/filter-modal.d.ts +16 -1
  90. package/dist/components/ui/filter-modal.d.ts.map +1 -1
  91. package/dist/components/ui/index.cjs +9 -3
  92. package/dist/components/ui/index.cjs.map +1 -1
  93. package/dist/components/ui/index.d.ts +1 -0
  94. package/dist/components/ui/index.d.ts.map +1 -1
  95. package/dist/components/ui/index.js +8 -2
  96. package/dist/components/ui/scroll-fade.d.ts +33 -0
  97. package/dist/components/ui/scroll-fade.d.ts.map +1 -0
  98. package/dist/hooks/index.cjs +4 -2
  99. package/dist/hooks/index.cjs.map +1 -1
  100. package/dist/hooks/index.js +3 -1
  101. package/dist/hooks/ui/index.d.ts +1 -0
  102. package/dist/hooks/ui/index.d.ts.map +1 -1
  103. package/dist/hooks/ui/use-deferred-error.d.ts +16 -0
  104. package/dist/hooks/ui/use-deferred-error.d.ts.map +1 -0
  105. package/dist/index.cjs +11 -3
  106. package/dist/index.cjs.map +1 -1
  107. package/dist/index.js +10 -2
  108. package/package.json +1 -1
  109. package/src/components/chat/chat-ticket-list.tsx +8 -36
  110. package/src/components/chat/guide-welcome.tsx +5 -43
  111. package/src/components/chat/mingo-chat-history.tsx +6 -40
  112. package/src/components/chat/mingo-welcome.tsx +7 -47
  113. package/src/components/features/auth/auth-benefits-panel.tsx +1 -1
  114. package/src/components/features/auth/complete-account-form.tsx +15 -4
  115. package/src/components/features/auth/create-organization-form.tsx +14 -5
  116. package/src/components/features/auth/login-form.tsx +6 -1
  117. package/src/components/features/auth/openframe-sso-login-form.tsx +9 -2
  118. package/src/components/features/auth/openframe-sso-signup-form.tsx +18 -5
  119. package/src/components/features/auth/password-reset-form.tsx +9 -2
  120. package/src/components/ui/data-table/data-table-header.tsx +6 -3
  121. package/src/components/ui/data-table/types.ts +6 -0
  122. package/src/components/ui/date-picker.tsx +149 -41
  123. package/src/components/ui/filter-modal.tsx +57 -2
  124. package/src/components/ui/index.ts +1 -0
  125. package/src/components/ui/scroll-fade.tsx +90 -0
  126. package/src/hooks/ui/index.ts +1 -0
  127. package/src/hooks/ui/use-deferred-error.ts +38 -0
  128. package/src/stories/DatePicker.stories.tsx +4 -2
  129. package/src/stories/FilterModal.stories.tsx +33 -0
  130. package/src/stories/Input.stories.tsx +1 -1
  131. package/src/stories/auth/CreateOrganization.stories.tsx +2 -1
  132. package/dist/chunk-2XVOYDRF.js.map +0 -1
  133. package/dist/chunk-7OKNKZBW.cjs.map +0 -1
  134. package/dist/chunk-JWBRDNYH.cjs.map +0 -1
  135. package/dist/chunk-ZSZRJWY3.js.map +0 -1
  136. /package/dist/{chunk-XBCAY6NE.js.map → chunk-2B5FIJE5.js.map} +0 -0
  137. /package/dist/{chunk-BSJO5VFV.js.map → chunk-5P5WMTVW.js.map} +0 -0
  138. /package/dist/{chunk-CRRH7RJX.js.map → chunk-B3IDZ6JJ.js.map} +0 -0
  139. /package/dist/{chunk-Z5ERSGP2.js.map → chunk-FLDYBM25.js.map} +0 -0
  140. /package/dist/{chunk-4KWIQEZT.js.map → chunk-MTBFE3JF.js.map} +0 -0
  141. /package/dist/{chunk-VVKWNVKE.js.map → chunk-MZPEN27A.js.map} +0 -0
  142. /package/dist/{chunk-G3A7QHCX.js.map → chunk-PMR74LEE.js.map} +0 -0
  143. /package/dist/{chunk-4K5XJ5EJ.js.map → chunk-RWKVTABY.js.map} +0 -0
  144. /package/dist/{chunk-FXWURWDU.js.map → chunk-TZDROKLI.js.map} +0 -0
  145. /package/dist/{chunk-4H2ICJYB.js.map → chunk-V3NY65ET.js.map} +0 -0
  146. /package/dist/{chunk-A6KGHQRF.js.map → chunk-V4YFWSB2.js.map} +0 -0
  147. /package/dist/{chunk-2L6ALD2Q.js.map → chunk-YON2QYWK.js.map} +0 -0
@@ -2,6 +2,7 @@
2
2
 
3
3
  import type * as React from 'react'
4
4
  import { cn } from '../../../utils/cn'
5
+ import { useDeferredError } from '../../../hooks/ui/use-deferred-error'
5
6
  import { Button } from '../../ui/button'
6
7
  import { Input } from '../../ui/input'
7
8
  import { PasswordInput } from '../../ui/password-input'
@@ -72,6 +73,13 @@ export function OpenFrameSsoSignUpForm({
72
73
  }: OpenFrameSsoSignUpFormProps) {
73
74
  const fieldsDisabled = disabled || loading
74
75
 
76
+ // Validation messages are deferred while the user is typing (shown on blur or after a pause).
77
+ const emailErr = useDeferredError(errors?.email, email)
78
+ const firstNameErr = useDeferredError(errors?.firstName, firstName)
79
+ const lastNameErr = useDeferredError(errors?.lastName, lastName)
80
+ const passwordErr = useDeferredError(errors?.password, password)
81
+ const confirmErr = useDeferredError(errors?.confirmPassword, confirmPassword)
82
+
75
83
  const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
76
84
  if (event.key === 'Enter' && !fieldsDisabled && !submitDisabled) {
77
85
  onSubmit()
@@ -96,8 +104,9 @@ export function OpenFrameSsoSignUpForm({
96
104
  label={emailLabel}
97
105
  placeholder="username@mail.com"
98
106
  value={email}
99
- error={errors?.email}
107
+ error={emailErr.error}
100
108
  disabled={fieldsDisabled || emailReadOnly}
109
+ onBlur={emailErr.onBlur}
101
110
  onChange={(event) => onEmailChange(event.target.value)}
102
111
  onKeyDown={handleKeyDown}
103
112
  />
@@ -107,8 +116,9 @@ export function OpenFrameSsoSignUpForm({
107
116
  label="First Name"
108
117
  placeholder="Enter First Name"
109
118
  value={firstName}
110
- error={errors?.firstName}
119
+ error={firstNameErr.error}
111
120
  disabled={fieldsDisabled}
121
+ onBlur={firstNameErr.onBlur}
112
122
  onChange={(event) => onFirstNameChange(event.target.value)}
113
123
  onKeyDown={handleKeyDown}
114
124
  />
@@ -116,8 +126,9 @@ export function OpenFrameSsoSignUpForm({
116
126
  label="Last Name"
117
127
  placeholder="Enter Last Name"
118
128
  value={lastName}
119
- error={errors?.lastName}
129
+ error={lastNameErr.error}
120
130
  disabled={fieldsDisabled}
131
+ onBlur={lastNameErr.onBlur}
121
132
  onChange={(event) => onLastNameChange(event.target.value)}
122
133
  onKeyDown={handleKeyDown}
123
134
  />
@@ -125,8 +136,9 @@ export function OpenFrameSsoSignUpForm({
125
136
  label="Password"
126
137
  placeholder="Enter Password"
127
138
  value={password}
128
- error={errors?.password}
139
+ error={passwordErr.error}
129
140
  disabled={fieldsDisabled}
141
+ onBlur={passwordErr.onBlur}
130
142
  onChange={(event) => onPasswordChange(event.target.value)}
131
143
  onKeyDown={handleKeyDown}
132
144
  />
@@ -134,8 +146,9 @@ export function OpenFrameSsoSignUpForm({
134
146
  label="Confirm Password"
135
147
  placeholder="Confirm Password"
136
148
  value={confirmPassword}
137
- error={errors?.confirmPassword}
149
+ error={confirmErr.error}
138
150
  disabled={fieldsDisabled}
151
+ onBlur={confirmErr.onBlur}
139
152
  onChange={(event) => onConfirmPasswordChange(event.target.value)}
140
153
  onKeyDown={handleKeyDown}
141
154
  />
@@ -2,6 +2,7 @@
2
2
 
3
3
  import type * as React from 'react'
4
4
  import { cn } from '../../../utils/cn'
5
+ import { useDeferredError } from '../../../hooks/ui/use-deferred-error'
5
6
  import { Button } from '../../ui/button'
6
7
  import { Input } from '../../ui/input'
7
8
  import { BackToLoginLink } from './back-to-login-link'
@@ -66,6 +67,10 @@ export function PasswordResetForm({
66
67
  }: PasswordResetFormProps) {
67
68
  const fieldsDisabled = disabled || loading
68
69
 
70
+ // Validation messages are deferred while the user is typing (shown on blur or after a pause).
71
+ const passwordErr = useDeferredError(errors?.password, password)
72
+ const confirmErr = useDeferredError(errors?.confirmPassword, confirmPassword)
73
+
69
74
  const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
70
75
  if (event.key === 'Enter' && !fieldsDisabled && !submitDisabled) {
71
76
  onSubmit()
@@ -91,8 +96,9 @@ export function PasswordResetForm({
91
96
  label={newPasswordLabel}
92
97
  placeholder={newPasswordPlaceholder}
93
98
  value={password}
94
- error={errors?.password}
99
+ error={passwordErr.error}
95
100
  disabled={fieldsDisabled}
101
+ onBlur={passwordErr.onBlur}
96
102
  onChange={(event) => onPasswordChange(event.target.value)}
97
103
  onKeyDown={handleKeyDown}
98
104
  />
@@ -103,8 +109,9 @@ export function PasswordResetForm({
103
109
  label={confirmPasswordLabel}
104
110
  placeholder={confirmPasswordPlaceholder}
105
111
  value={confirmPassword}
106
- error={errors?.confirmPassword}
112
+ error={confirmErr.error}
107
113
  disabled={fieldsDisabled}
114
+ onBlur={confirmErr.onBlur}
108
115
  onChange={(event) => onConfirmPasswordChange(event.target.value)}
109
116
  onKeyDown={handleKeyDown}
110
117
  />
@@ -69,7 +69,8 @@ export function DataTableHeader({
69
69
  const hasVisibleHeaderCell = headerGroup.headers.some(header => {
70
70
  if (header.isPlaceholder) return false
71
71
  if (isLgUp) return true
72
- return Boolean(header.column.columnDef.meta?.filter)
72
+ const meta = header.column.columnDef.meta
73
+ return Boolean(meta?.filter) || meta?.alwaysShowHeader === true
73
74
  })
74
75
 
75
76
  return (
@@ -122,6 +123,8 @@ function HeaderCell({ header, isLgUp, sort, onSortChange }: HeaderCellProps) {
122
123
  const column = header.column
123
124
  const meta = column.columnDef.meta
124
125
  const hasFilter = Boolean(meta?.filter)
126
+ // Tablet visibility: filterable columns and explicit opt-ins stay rendered.
127
+ const keepOnTablet = hasFilter || meta?.alwaysShowHeader === true
125
128
  const align = meta?.align ?? 'left'
126
129
  // Sort is opt-in via `meta.sortable`. Direction is fully consumer-driven via
127
130
  // the `sort` prop; we do not consult TanStack's sort APIs here.
@@ -130,7 +133,7 @@ function HeaderCell({ header, isLgUp, sort, onSortChange }: HeaderCellProps) {
130
133
  sort?.id === column.id ? (sort.desc ? 'desc' : 'asc') : false
131
134
 
132
135
  // Mobile (md, below lg): hide non-filter columns so only filters are accessible.
133
- if (!isLgUp && !hasFilter) return null
136
+ if (!isLgUp && !keepOnTablet) return null
134
137
 
135
138
  return (
136
139
  <div
@@ -139,7 +142,7 @@ function HeaderCell({ header, isLgUp, sort, onSortChange }: HeaderCellProps) {
139
142
  isLgUp && (meta?.width || 'flex-1 min-w-0'),
140
143
  meta?.headerClassName,
141
144
  // Don't apply hide classes if column is filterable on tablet (keep filter accessible)
142
- !(hasFilter && !isLgUp) && getHideClasses(meta?.hideAt),
145
+ !(keepOnTablet && !isLgUp) && getHideClasses(meta?.hideAt),
143
146
  )}
144
147
  >
145
148
  {hasFilter ? (
@@ -16,6 +16,12 @@ declare module '@tanstack/react-table' {
16
16
  align?: 'left' | 'center' | 'right'
17
17
  /** Hide column at/below these Tailwind breakpoints. */
18
18
  hideAt?: TailwindBreakpoint | TailwindBreakpoint[]
19
+ /**
20
+ * Keep this header visible on tablet (md, below lg) even without
21
+ * `meta.filter` — for custom headers hosting their own filter UI
22
+ * (e.g. the date filter calendar trigger).
23
+ */
24
+ alwaysShowHeader?: boolean
19
25
  /** If present, header renders a filter dropdown with these options. */
20
26
  filter?: {
21
27
  options: DataTableFilterOption[]
@@ -150,6 +150,14 @@ function DatePickerCalendar({
150
150
  );
151
151
  const [hoveredDate, setHoveredDate] = React.useState<Date | undefined>(undefined);
152
152
 
153
+ // Keep the internal range in sync when the consumer changes `selected`
154
+ // externally (e.g. DateFilterMenu Reset while the popover stays open).
155
+ React.useEffect(() => {
156
+ if (mode === "range") {
157
+ setDraftRange(selected as DateRange | undefined);
158
+ }
159
+ }, [mode, selected]);
160
+
153
161
  const rangeSelected = draftRange;
154
162
  const hasCompleteRange =
155
163
  mode === "range" &&
@@ -168,7 +176,11 @@ function DatePickerCalendar({
168
176
  if (!triggerDate) return;
169
177
 
170
178
  if (!draftRange?.from || draftRange.to) {
171
- setDraftRange({ from: triggerDate, to: undefined });
179
+ // First click starts the range — propagate it so consumers can already
180
+ // act on a single-day selection (e.g. DateFilterMenu Apply/Reset).
181
+ const started: DateRange = { from: triggerDate, to: undefined };
182
+ setDraftRange(started);
183
+ onSelect(started);
172
184
  return;
173
185
  }
174
186
  // Second click closes the range, ordering the two ends.
@@ -981,6 +993,89 @@ export function DatePickerInputSimple({
981
993
  );
982
994
  }
983
995
 
996
+ // ============================================================================
997
+ // DateFilterPanel Component (sort select + fluid calendar)
998
+ // ============================================================================
999
+
1000
+ export interface DateFilterPanelProps {
1001
+ /** Selection mode for the calendar. Defaults to "range". */
1002
+ mode?: DatePickerMode;
1003
+ /** Current sort direction shown in the select. */
1004
+ sort: SortDirection;
1005
+ onSortChange: (sort: SortDirection) => void;
1006
+ /** Current calendar selection. */
1007
+ selected: Date | DateRange | undefined;
1008
+ onSelect: (value: Date | DateRange | undefined) => void;
1009
+ /** Minimum selectable date. */
1010
+ fromDate?: Date;
1011
+ /** Maximum selectable date. */
1012
+ toDate?: Date;
1013
+ /** Locale for the calendar. */
1014
+ locale?: DayPickerProps["locale"];
1015
+ /** Label for the ascending sort option. */
1016
+ ascLabel?: string;
1017
+ /** Label for the descending sort option. */
1018
+ descLabel?: string;
1019
+ className?: string;
1020
+ }
1021
+
1022
+ /**
1023
+ * DateFilterPanel — the controlled sort-direction select + fluid calendar
1024
+ * block shared by DateFilterMenu (popover) and FilterModal (mobile "Sort and
1025
+ * Filter"). Owns no state: the consumer drafts and commits the values.
1026
+ */
1027
+ export function DateFilterPanel({
1028
+ mode = "range",
1029
+ sort,
1030
+ onSortChange,
1031
+ selected,
1032
+ onSelect,
1033
+ fromDate,
1034
+ toDate,
1035
+ locale,
1036
+ ascLabel = "Sort by Ascending",
1037
+ descLabel = "Sort by Descending",
1038
+ className,
1039
+ }: DateFilterPanelProps) {
1040
+ return (
1041
+ <div className={cn("flex flex-col gap-4", className)}>
1042
+ {/* Sort direction selector */}
1043
+ <Select
1044
+ value={sort}
1045
+ onValueChange={(value) => onSortChange(value as SortDirection)}
1046
+ >
1047
+ <SelectTrigger className="gap-2" aria-label="Sort direction">
1048
+ {/* Wrapper is a <div> (not <span>) so SelectTrigger's
1049
+ `[&>span]:line-clamp-1` rule doesn't force it to a vertical
1050
+ -webkit-box and stack the icon/label into a column. */}
1051
+ <div className="flex min-w-0 flex-1 items-center gap-2">
1052
+ <ArrowUpDown className="size-6 shrink-0 text-ods-text-secondary" />
1053
+ <span className="truncate">
1054
+ <SelectValue />
1055
+ </span>
1056
+ </div>
1057
+ </SelectTrigger>
1058
+ <SelectContent>
1059
+ <SelectItem value="asc">{ascLabel}</SelectItem>
1060
+ <SelectItem value="desc">{descLabel}</SelectItem>
1061
+ </SelectContent>
1062
+ </Select>
1063
+
1064
+ {/* Calendar */}
1065
+ <DatePickerCalendar
1066
+ mode={mode}
1067
+ selected={selected}
1068
+ onSelect={onSelect}
1069
+ numberOfMonths={1}
1070
+ fromDate={fromDate}
1071
+ toDate={toDate}
1072
+ locale={locale}
1073
+ fluid
1074
+ />
1075
+ </div>
1076
+ );
1077
+ }
1078
+
984
1079
  // ============================================================================
985
1080
  // DateFilterMenu Component (sort + calendar filter popover from Figma)
986
1081
  // ============================================================================
@@ -999,14 +1094,21 @@ export interface DateFilterMenuProps {
999
1094
  mode?: DatePickerMode;
1000
1095
  /** Current (applied) sort direction. Defaults to "desc". */
1001
1096
  sort?: SortDirection;
1097
+ /** Baseline sort direction — Reset restores it, and a draft that differs
1098
+ * from it counts as an active change (shows Reset). Defaults to "desc". */
1099
+ defaultSort?: SortDirection;
1002
1100
  /** Current (applied) single date — used when mode === "single". */
1003
1101
  date?: Date;
1004
1102
  /** Current (applied) range — used when mode === "range". */
1005
1103
  range?: DateRange;
1006
- /** Fired when the user presses Apply with the drafted selection. */
1104
+ /** Fired when the user presses Apply with the drafted selection. Also fired
1105
+ * by Reset with a cleared selection so the consumer refetches unfiltered data. */
1007
1106
  onApply?: (result: DateFilterResult) => void;
1008
1107
  /** Fired when the menu closes (Close button, outside click, Esc). */
1009
1108
  onClose?: () => void;
1109
+ /** Custom trigger element (rendered via Radix `asChild` — must accept a ref,
1110
+ * e.g. a native button). Defaults to the outline calendar icon Button. */
1111
+ trigger?: React.ReactNode;
1010
1112
  /** Disable the trigger. */
1011
1113
  disabled?: boolean;
1012
1114
  /** Minimum selectable date. */
@@ -1029,16 +1131,21 @@ export interface DateFilterMenuProps {
1029
1131
 
1030
1132
  /**
1031
1133
  * DateFilterMenu — a calendar-icon-triggered popover combining a sort-direction
1032
- * selector and a date / date-range calendar, with Close and Apply actions.
1033
- * Selections are drafted internally and only committed via `onApply`.
1134
+ * selector and a date / date-range calendar, with Close/Reset and Apply actions.
1135
+ * The sort and date selection are drafted internally and committed via
1136
+ * `onApply`. While a date is selected, Close is replaced by Reset, which
1137
+ * clears and commits the empty selection (fires `onApply`) so the consumer
1138
+ * drops the filter.
1034
1139
  */
1035
1140
  export function DateFilterMenu({
1036
1141
  mode = "range",
1037
1142
  sort = "desc",
1143
+ defaultSort = "desc",
1038
1144
  date,
1039
1145
  range,
1040
1146
  onApply,
1041
1147
  onClose,
1148
+ trigger,
1042
1149
  disabled = false,
1043
1150
  fromDate,
1044
1151
  toDate,
@@ -1085,18 +1192,39 @@ export function DateFilterMenu({
1085
1192
  setOpen(false);
1086
1193
  };
1087
1194
 
1195
+ // Anything to reset? A calendar selection or a non-default sort — drives Close vs Reset.
1196
+ const hasSelection =
1197
+ mode === "single"
1198
+ ? Boolean(draftSelected)
1199
+ : Boolean((draftSelected as DateRange | undefined)?.from);
1200
+ const hasChanges = hasSelection || draftSort !== defaultSort;
1201
+
1202
+ // Reset restores the defaults and commits them so the consumer drops the
1203
+ // filter and refetches; the menu stays open with the button back to Close.
1204
+ const handleReset = () => {
1205
+ setDraftSort(defaultSort);
1206
+ setDraftSelected(undefined);
1207
+ onApply?.(
1208
+ mode === "single"
1209
+ ? { sort: defaultSort, date: undefined }
1210
+ : { sort: defaultSort, range: undefined }
1211
+ );
1212
+ };
1213
+
1088
1214
  return (
1089
1215
  <Popover.Root open={open} onOpenChange={handleOpenChange}>
1090
1216
  <Popover.Trigger asChild>
1091
- <Button
1092
- type="button"
1093
- variant="outline"
1094
- size="icon"
1095
- disabled={disabled}
1096
- aria-label={ariaLabel}
1097
- className={className}
1098
- leftIcon={<Calendar className="size-6" />}
1099
- />
1217
+ {trigger ?? (
1218
+ <Button
1219
+ type="button"
1220
+ variant="outline"
1221
+ size="icon"
1222
+ disabled={disabled}
1223
+ aria-label={ariaLabel}
1224
+ className={className}
1225
+ leftIcon={<Calendar className="size-6" />}
1226
+ />
1227
+ )}
1100
1228
  </Popover.Trigger>
1101
1229
 
1102
1230
  <Popover.Portal>
@@ -1112,38 +1240,18 @@ export function DateFilterMenu({
1112
1240
  sideOffset={8}
1113
1241
  align={align}
1114
1242
  >
1115
- {/* Sort direction selector */}
1116
- <Select
1117
- value={draftSort}
1118
- onValueChange={(value) => setDraftSort(value as SortDirection)}
1119
- >
1120
- <SelectTrigger className="gap-2" aria-label="Sort direction">
1121
- {/* Wrapper is a <div> (not <span>) so SelectTrigger's
1122
- `[&>span]:line-clamp-1` rule doesn't force it to a vertical
1123
- -webkit-box and stack the icon/label into a column. */}
1124
- <div className="flex min-w-0 flex-1 items-center gap-2">
1125
- <ArrowUpDown className="size-6 shrink-0 text-ods-text-secondary" />
1126
- <span className="truncate">
1127
- <SelectValue />
1128
- </span>
1129
- </div>
1130
- </SelectTrigger>
1131
- <SelectContent>
1132
- <SelectItem value="asc">{ascLabel}</SelectItem>
1133
- <SelectItem value="desc">{descLabel}</SelectItem>
1134
- </SelectContent>
1135
- </Select>
1136
-
1137
- {/* Calendar */}
1138
- <DatePickerCalendar
1243
+ {/* Sort + calendar (shared panel) */}
1244
+ <DateFilterPanel
1139
1245
  mode={mode}
1246
+ sort={draftSort}
1247
+ onSortChange={setDraftSort}
1140
1248
  selected={draftSelected}
1141
1249
  onSelect={setDraftSelected}
1142
- numberOfMonths={1}
1143
1250
  fromDate={fromDate}
1144
1251
  toDate={toDate}
1145
1252
  locale={locale}
1146
- fluid
1253
+ ascLabel={ascLabel}
1254
+ descLabel={descLabel}
1147
1255
  />
1148
1256
 
1149
1257
  {/* Actions */}
@@ -1152,9 +1260,9 @@ export function DateFilterMenu({
1152
1260
  type="button"
1153
1261
  variant="outline"
1154
1262
  fullWidth
1155
- onClick={handleClose}
1263
+ onClick={hasChanges ? handleReset : handleClose}
1156
1264
  >
1157
- Close
1265
+ {hasChanges ? "Reset" : "Close"}
1158
1266
  </Button>
1159
1267
  <Button
1160
1268
  type="button"
@@ -6,7 +6,9 @@ import { cn } from '../../utils/cn'
6
6
  import { Filter02Icon } from '../icons-v2-generated/sort-and-filter/filter-02-icon'
7
7
  import { Button } from './button'
8
8
  import { FilterCheckboxItem } from './filter-checkbox-item'
9
+ import { DateFilterPanel, type DateFilterResult, type DateRange } from './date-picker'
9
10
  import { ModalV2, ModalV2Content, ModalV2Footer, ModalV2Header, ModalV2Title } from './modal-v2'
11
+ import { ScrollFadeOverlay, useScrollFade } from './scroll-fade'
10
12
  import { Skeleton } from './skeleton'
11
13
  import { SortColumnItem, type SortConfig, type SortDirection } from './sort-column-item'
12
14
  import { TagKeyValueFilter, type TagKeyConfig } from './tag-key-value-filter'
@@ -51,6 +53,20 @@ export interface FilterModalProps {
51
53
  tagFilterTitle?: string
52
54
  /** Show skeleton loading state */
53
55
  isLoading?: boolean
56
+ /** Date sort + range filter rendered as the last section. Drafted with the
57
+ * other filters and committed via `onChange` on Apply (defaults on Reset). */
58
+ dateFilter?: {
59
+ /** Section title. Defaults to 'Date'. */
60
+ title?: string
61
+ /** Applied sort direction. Defaults to `defaultSort`. */
62
+ sort?: SortDirection
63
+ /** Baseline sort direction restored by Reset. Defaults to 'desc'. */
64
+ defaultSort?: SortDirection
65
+ /** Applied date range. */
66
+ range?: DateRange
67
+ /** Fired on Apply with the drafted values, and on Reset with the defaults. */
68
+ onChange: (result: DateFilterResult) => void
69
+ }
54
70
  /** Text shown in empty state title */
55
71
  emptyStateTitle?: string
56
72
  /** Text shown in empty state description */
@@ -74,6 +90,7 @@ export function FilterModal({
74
90
  selectedTags,
75
91
  onTagsChange,
76
92
  tagFilterTitle,
93
+ dateFilter,
77
94
  isLoading = false,
78
95
  emptyStateTitle = 'No filters available',
79
96
  emptyStateDescription = 'There are no filter options to display at the moment',
@@ -83,10 +100,20 @@ export function FilterModal({
83
100
  })
84
101
  const [pendingTags, setPendingTags] = useState<string[]>(selectedTags ?? [])
85
102
 
103
+ const dateDefaultSort: SortDirection = dateFilter?.defaultSort ?? 'desc'
104
+ const [draftDateSort, setDraftDateSort] = useState<SortDirection>(dateFilter?.sort ?? dateDefaultSort)
105
+ const [draftDateRange, setDraftDateRange] = useState<DateRange | undefined>(dateFilter?.range)
106
+
107
+ // Scroll shadows: bottom fade by default while content continues below,
108
+ // top fade once scrolled (same pattern as the chat lists).
109
+ const { scrollRef, fadeTop, fadeBottom, update: updateFade } = useScrollFade<HTMLDivElement>()
110
+
86
111
  useEffect(() => {
87
112
  if (isOpen) {
88
113
  setSelectedFilters({ ...currentFilters })
89
114
  setPendingTags(selectedTags ?? [])
115
+ setDraftDateSort(dateFilter?.sort ?? dateDefaultSort)
116
+ setDraftDateRange(dateFilter?.range)
90
117
  }
91
118
  }, [isOpen, JSON.stringify(currentFilters), JSON.stringify(selectedTags)])
92
119
 
@@ -109,12 +136,14 @@ export function FilterModal({
109
136
  const handleReset = () => {
110
137
  onFilterChange({})
111
138
  onTagsChange?.([])
139
+ dateFilter?.onChange({ sort: dateDefaultSort, range: undefined })
112
140
  onClose()
113
141
  }
114
142
 
115
143
  const handleApply = () => {
116
144
  onFilterChange(selectedFilters)
117
145
  onTagsChange?.(pendingTags)
146
+ dateFilter?.onChange({ sort: draftDateSort, range: draftDateRange })
118
147
  onClose()
119
148
  }
120
149
 
@@ -125,7 +154,7 @@ export function FilterModal({
125
154
  const hasSort = !!sortConfig && sortConfig.columns.length > 0
126
155
  const hasFilterGroups = filterGroups.length > 0
127
156
  const hasTagFilter = !!tagFilterKeys && tagFilterKeys.length > 0 && !!onTagsChange
128
- const isEmpty = !isLoading && !hasSort && !hasFilterGroups && !hasTagFilter
157
+ const isEmpty = !isLoading && !hasSort && !hasFilterGroups && !hasTagFilter && !dateFilter
129
158
 
130
159
  return (
131
160
  <ModalV2
@@ -143,7 +172,12 @@ export function FilterModal({
143
172
  !isEmpty && 'md:bg-ods-bg md:border md:border-ods-border md:rounded-md md:px-3',
144
173
  )}
145
174
  >
146
- <div className="flex-1 min-h-0 overflow-y-auto flex flex-col gap-4 md:py-3">
175
+ <div className="relative flex-1 min-h-0 flex flex-col">
176
+ <div
177
+ ref={scrollRef}
178
+ onScroll={updateFade}
179
+ className="flex-1 min-h-0 overflow-y-auto flex flex-col gap-4 md:py-3"
180
+ >
147
181
  {isLoading ? (
148
182
  <>
149
183
  {[0, 1].map((group) => (
@@ -229,10 +263,31 @@ export function FilterModal({
229
263
  onTagsChange={setPendingTags}
230
264
  keysTitle={tagFilterTitle}
231
265
  />
266
+ )}
267
+ {/* Date sort + range filter */}
268
+ {dateFilter && (
269
+ <div className="flex flex-col gap-1">
270
+ <span className="text-h5 text-ods-text-secondary">
271
+ {dateFilter.title ?? 'Date'}
272
+ </span>
273
+ <DateFilterPanel
274
+ mode="range"
275
+ sort={draftDateSort}
276
+ onSortChange={setDraftDateSort}
277
+ selected={draftDateRange}
278
+ onSelect={(value) => setDraftDateRange(value as DateRange | undefined)}
279
+ className="gap-[var(--spacing-system-xxs)]"
280
+ />
281
+ </div>
232
282
  )}
233
283
  </>
234
284
  )}
235
285
  </div>
286
+
287
+ {/* Scroll shadows over the filter list edges */}
288
+ <ScrollFadeOverlay edge="top" visible={fadeTop} />
289
+ <ScrollFadeOverlay edge="bottom" visible={fadeBottom} />
290
+ </div>
236
291
  </ModalV2Content>
237
292
 
238
293
  <ModalV2Footer>
@@ -24,6 +24,7 @@ export * from './no-data'
24
24
  export * from './page-loader'
25
25
  export * from './progress-bar'
26
26
  export * from './radio-group'
27
+ export * from './scroll-fade'
27
28
  export * from './select'
28
29
  export * from './switch'
29
30
  export * from './tag-select-dropdown'
@@ -0,0 +1,90 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import { cn } from '../../utils/cn'
5
+
6
+ /**
7
+ * Tracks whether a scrollable element is scrolled away from its top/bottom
8
+ * edge, so edge fades ("scroll shadows") can be shown only where content
9
+ * continues. Attach `scrollRef` to the scrollable element and `update` to its
10
+ * `onScroll`; content/size changes are re-measured via ResizeObserver.
11
+ *
12
+ * Canonical fade pattern shared by chat lists and FilterModal.
13
+ */
14
+ export function useScrollFade<T extends HTMLElement = HTMLDivElement>() {
15
+ const scrollRef = React.useRef<T | null>(null)
16
+ const [fade, setFade] = React.useState({ top: false, bottom: false })
17
+
18
+ const update = React.useCallback(() => {
19
+ const el = scrollRef.current
20
+ if (!el) return
21
+ setFade((prev) => {
22
+ const top = el.scrollTop > 0
23
+ const bottom = el.scrollHeight - el.scrollTop - el.clientHeight > 1
24
+ return prev.top === top && prev.bottom === bottom ? prev : { top, bottom }
25
+ })
26
+ }, [])
27
+
28
+ React.useLayoutEffect(() => {
29
+ update()
30
+ const el = scrollRef.current
31
+ if (!el) return
32
+ const cleanups: Array<() => void> = []
33
+ if (typeof ResizeObserver !== 'undefined') {
34
+ const resizeObserver = new ResizeObserver(update)
35
+ resizeObserver.observe(el)
36
+ cleanups.push(() => resizeObserver.disconnect())
37
+ }
38
+ // ResizeObserver only sees the container box; content growth (appended
39
+ // list items) changes scrollHeight without resizing it — watch mutations.
40
+ if (typeof MutationObserver !== 'undefined') {
41
+ const mutationObserver = new MutationObserver(update)
42
+ mutationObserver.observe(el, { childList: true, subtree: true, characterData: true })
43
+ cleanups.push(() => mutationObserver.disconnect())
44
+ }
45
+ return () => {
46
+ for (const cleanup of cleanups) cleanup()
47
+ }
48
+ }, [update])
49
+
50
+ return { scrollRef, fadeTop: fade.top, fadeBottom: fade.bottom, update }
51
+ }
52
+
53
+ export interface ScrollFadeOverlayProps {
54
+ /** Which edge of the scroll container the fade sits on. */
55
+ edge: 'top' | 'bottom'
56
+ /** Whether the fade is currently shown (content continues past this edge). */
57
+ visible: boolean
58
+ /**
59
+ * CSS color the content fades into — should match the surface behind the
60
+ * list. Defaults to the page background token.
61
+ */
62
+ color?: string
63
+ className?: string
64
+ }
65
+
66
+ /**
67
+ * Edge overlay that fades scrollable content into the surface behind it.
68
+ * Render inside a `relative` wrapper around the scrollable element.
69
+ */
70
+ export function ScrollFadeOverlay({
71
+ edge,
72
+ visible,
73
+ color = 'var(--color-bg)',
74
+ className,
75
+ }: ScrollFadeOverlayProps) {
76
+ return (
77
+ <div
78
+ aria-hidden
79
+ className={cn(
80
+ 'pointer-events-none absolute inset-x-0 h-16 transition-opacity duration-150',
81
+ edge === 'top' ? 'top-0' : 'bottom-0',
82
+ visible ? 'opacity-100' : 'opacity-0',
83
+ className,
84
+ )}
85
+ style={{
86
+ background: `linear-gradient(${edge === 'top' ? '0deg' : '180deg'}, transparent 0%, ${color} 100%)`,
87
+ }}
88
+ />
89
+ )
90
+ }
@@ -1,6 +1,7 @@
1
1
  // UI Hooks exports
2
2
  export * from './use-auto-limit-tags'
3
3
  export * from './use-debounce'
4
+ export * from './use-deferred-error'
4
5
  export * from './use-header-height'
5
6
  export * from './use-horizontal-scrollbar'
6
7
  export * from './use-image-edge-color'