@oxy.so/bloom 2.11.0 → 2.12.0
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.
- package/docs/app-shell.mdx +90 -4
- package/docs/content-panel.mdx +65 -19
- package/docs/date-picker.mdx +80 -2
- package/docs/listing-card.mdx +54 -0
- package/docs/rating.mdx +52 -2
- package/docs/stay-search.mdx +38 -0
- package/lib/commonjs/app-shell/AppShell.js +121 -5
- package/lib/commonjs/app-shell/AppShell.js.map +1 -1
- package/lib/commonjs/app-shell/layout.js +10 -5
- package/lib/commonjs/app-shell/layout.js.map +1 -1
- package/lib/commonjs/content-panel/ContentPanel.js +8 -2
- package/lib/commonjs/content-panel/ContentPanel.js.map +1 -1
- package/lib/commonjs/content-panel/ContentPanel.web.js +38 -25
- package/lib/commonjs/content-panel/ContentPanel.web.js.map +1 -1
- package/lib/commonjs/date-picker/TimeField.js +175 -0
- package/lib/commonjs/date-picker/TimeField.js.map +1 -0
- package/lib/commonjs/date-picker/index.js +7 -0
- package/lib/commonjs/date-picker/index.js.map +1 -1
- package/lib/commonjs/date-picker/time.js +160 -0
- package/lib/commonjs/date-picker/time.js.map +1 -0
- package/lib/commonjs/index.js +7 -0
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/index.web.js +7 -0
- package/lib/commonjs/index.web.js.map +1 -1
- package/lib/commonjs/listing-card/ListingCard.js +34 -1
- package/lib/commonjs/listing-card/ListingCard.js.map +1 -1
- package/lib/commonjs/listing-card/WishlistCard.js +50 -8
- package/lib/commonjs/listing-card/WishlistCard.js.map +1 -1
- package/lib/commonjs/listing-card/shared.js +23 -1
- package/lib/commonjs/listing-card/shared.js.map +1 -1
- package/lib/commonjs/rating/RatingInput.js +218 -0
- package/lib/commonjs/rating/RatingInput.js.map +1 -0
- package/lib/commonjs/rating/index.js +7 -0
- package/lib/commonjs/rating/index.js.map +1 -1
- package/lib/commonjs/stay-search/DestinationSuggestions.js +46 -9
- package/lib/commonjs/stay-search/DestinationSuggestions.js.map +1 -1
- package/lib/commonjs/stay-search/GuestPicker.js +9 -0
- package/lib/commonjs/stay-search/GuestPicker.js.map +1 -1
- package/lib/commonjs/track-list/parts.js +0 -13
- package/lib/commonjs/track-list/parts.js.map +1 -1
- package/lib/module/app-shell/AppShell.js +121 -5
- package/lib/module/app-shell/AppShell.js.map +1 -1
- package/lib/module/app-shell/layout.js +10 -5
- package/lib/module/app-shell/layout.js.map +1 -1
- package/lib/module/content-panel/ContentPanel.js +8 -2
- package/lib/module/content-panel/ContentPanel.js.map +1 -1
- package/lib/module/content-panel/ContentPanel.web.js +38 -25
- package/lib/module/content-panel/ContentPanel.web.js.map +1 -1
- package/lib/module/date-picker/TimeField.js +170 -0
- package/lib/module/date-picker/TimeField.js.map +1 -0
- package/lib/module/date-picker/index.js +3 -0
- package/lib/module/date-picker/index.js.map +1 -1
- package/lib/module/date-picker/time.js +151 -0
- package/lib/module/date-picker/time.js.map +1 -0
- package/lib/module/index.js +1 -1
- package/lib/module/index.js.map +1 -1
- package/lib/module/index.web.js +1 -1
- package/lib/module/index.web.js.map +1 -1
- package/lib/module/listing-card/ListingCard.js +34 -1
- package/lib/module/listing-card/ListingCard.js.map +1 -1
- package/lib/module/listing-card/WishlistCard.js +50 -8
- package/lib/module/listing-card/WishlistCard.js.map +1 -1
- package/lib/module/listing-card/shared.js +22 -0
- package/lib/module/listing-card/shared.js.map +1 -1
- package/lib/module/rating/RatingInput.js +212 -0
- package/lib/module/rating/RatingInput.js.map +1 -0
- package/lib/module/rating/index.js +1 -0
- package/lib/module/rating/index.js.map +1 -1
- package/lib/module/stay-search/DestinationSuggestions.js +45 -9
- package/lib/module/stay-search/DestinationSuggestions.js.map +1 -1
- package/lib/module/stay-search/GuestPicker.js +9 -0
- package/lib/module/stay-search/GuestPicker.js.map +1 -1
- package/lib/module/track-list/parts.js +1 -14
- package/lib/module/track-list/parts.js.map +1 -1
- package/lib/typescript/commonjs/app-shell/AppShell.d.ts.map +1 -1
- package/lib/typescript/commonjs/app-shell/layout.d.ts +5 -4
- package/lib/typescript/commonjs/app-shell/layout.d.ts.map +1 -1
- package/lib/typescript/commonjs/app-shell/types.d.ts +12 -2
- package/lib/typescript/commonjs/app-shell/types.d.ts.map +1 -1
- package/lib/typescript/commonjs/content-panel/ContentPanel.d.ts.map +1 -1
- package/lib/typescript/commonjs/content-panel/ContentPanel.web.d.ts.map +1 -1
- package/lib/typescript/commonjs/content-panel/types.d.ts +53 -42
- package/lib/typescript/commonjs/content-panel/types.d.ts.map +1 -1
- package/lib/typescript/commonjs/date-picker/TimeField.d.ts +6 -0
- package/lib/typescript/commonjs/date-picker/TimeField.d.ts.map +1 -0
- package/lib/typescript/commonjs/date-picker/index.d.ts +2 -1
- package/lib/typescript/commonjs/date-picker/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/date-picker/time.d.ts +56 -0
- package/lib/typescript/commonjs/date-picker/time.d.ts.map +1 -0
- package/lib/typescript/commonjs/date-picker/types.d.ts +49 -3
- package/lib/typescript/commonjs/date-picker/types.d.ts.map +1 -1
- package/lib/typescript/commonjs/index.d.ts +2 -2
- package/lib/typescript/commonjs/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/index.web.d.ts +2 -2
- package/lib/typescript/commonjs/index.web.d.ts.map +1 -1
- package/lib/typescript/commonjs/listing-card/ListingCard.d.ts.map +1 -1
- package/lib/typescript/commonjs/listing-card/WishlistCard.d.ts +1 -1
- package/lib/typescript/commonjs/listing-card/WishlistCard.d.ts.map +1 -1
- package/lib/typescript/commonjs/listing-card/shared.d.ts +8 -1
- package/lib/typescript/commonjs/listing-card/shared.d.ts.map +1 -1
- package/lib/typescript/commonjs/listing-card/types.d.ts +37 -0
- package/lib/typescript/commonjs/listing-card/types.d.ts.map +1 -1
- package/lib/typescript/commonjs/rating/RatingInput.d.ts +8 -0
- package/lib/typescript/commonjs/rating/RatingInput.d.ts.map +1 -0
- package/lib/typescript/commonjs/rating/index.d.ts +2 -1
- package/lib/typescript/commonjs/rating/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/rating/types.d.ts +31 -0
- package/lib/typescript/commonjs/rating/types.d.ts.map +1 -1
- package/lib/typescript/commonjs/stay-search/DestinationSuggestions.d.ts +8 -0
- package/lib/typescript/commonjs/stay-search/DestinationSuggestions.d.ts.map +1 -1
- package/lib/typescript/commonjs/stay-search/GuestPicker.d.ts +6 -1
- package/lib/typescript/commonjs/stay-search/GuestPicker.d.ts.map +1 -1
- package/lib/typescript/commonjs/stay-search/types.d.ts +20 -0
- package/lib/typescript/commonjs/stay-search/types.d.ts.map +1 -1
- package/lib/typescript/commonjs/track-list/parts.d.ts.map +1 -1
- package/lib/typescript/module/app-shell/AppShell.d.ts.map +1 -1
- package/lib/typescript/module/app-shell/layout.d.ts +5 -4
- package/lib/typescript/module/app-shell/layout.d.ts.map +1 -1
- package/lib/typescript/module/app-shell/types.d.ts +12 -2
- package/lib/typescript/module/app-shell/types.d.ts.map +1 -1
- package/lib/typescript/module/content-panel/ContentPanel.d.ts.map +1 -1
- package/lib/typescript/module/content-panel/ContentPanel.web.d.ts.map +1 -1
- package/lib/typescript/module/content-panel/types.d.ts +53 -42
- package/lib/typescript/module/content-panel/types.d.ts.map +1 -1
- package/lib/typescript/module/date-picker/TimeField.d.ts +6 -0
- package/lib/typescript/module/date-picker/TimeField.d.ts.map +1 -0
- package/lib/typescript/module/date-picker/index.d.ts +2 -1
- package/lib/typescript/module/date-picker/index.d.ts.map +1 -1
- package/lib/typescript/module/date-picker/time.d.ts +56 -0
- package/lib/typescript/module/date-picker/time.d.ts.map +1 -0
- package/lib/typescript/module/date-picker/types.d.ts +49 -3
- package/lib/typescript/module/date-picker/types.d.ts.map +1 -1
- package/lib/typescript/module/index.d.ts +2 -2
- package/lib/typescript/module/index.d.ts.map +1 -1
- package/lib/typescript/module/index.web.d.ts +2 -2
- package/lib/typescript/module/index.web.d.ts.map +1 -1
- package/lib/typescript/module/listing-card/ListingCard.d.ts.map +1 -1
- package/lib/typescript/module/listing-card/WishlistCard.d.ts +1 -1
- package/lib/typescript/module/listing-card/WishlistCard.d.ts.map +1 -1
- package/lib/typescript/module/listing-card/shared.d.ts +8 -1
- package/lib/typescript/module/listing-card/shared.d.ts.map +1 -1
- package/lib/typescript/module/listing-card/types.d.ts +37 -0
- package/lib/typescript/module/listing-card/types.d.ts.map +1 -1
- package/lib/typescript/module/rating/RatingInput.d.ts +8 -0
- package/lib/typescript/module/rating/RatingInput.d.ts.map +1 -0
- package/lib/typescript/module/rating/index.d.ts +2 -1
- package/lib/typescript/module/rating/index.d.ts.map +1 -1
- package/lib/typescript/module/rating/types.d.ts +31 -0
- package/lib/typescript/module/rating/types.d.ts.map +1 -1
- package/lib/typescript/module/stay-search/DestinationSuggestions.d.ts +8 -0
- package/lib/typescript/module/stay-search/DestinationSuggestions.d.ts.map +1 -1
- package/lib/typescript/module/stay-search/GuestPicker.d.ts +6 -1
- package/lib/typescript/module/stay-search/GuestPicker.d.ts.map +1 -1
- package/lib/typescript/module/stay-search/types.d.ts +20 -0
- package/lib/typescript/module/stay-search/types.d.ts.map +1 -1
- package/lib/typescript/module/track-list/parts.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/app-shell/AppShell.tsx +91 -4
- package/src/app-shell/layout.ts +10 -5
- package/src/app-shell/types.ts +12 -2
- package/src/content-panel/ContentPanel.tsx +8 -2
- package/src/content-panel/ContentPanel.web.tsx +39 -25
- package/src/content-panel/types.ts +50 -42
- package/src/date-picker/TimeField.tsx +162 -0
- package/src/date-picker/index.ts +6 -0
- package/src/date-picker/time.ts +158 -0
- package/src/date-picker/types.ts +52 -3
- package/src/index.ts +9 -2
- package/src/index.web.ts +9 -2
- package/src/listing-card/ListingCard.tsx +34 -1
- package/src/listing-card/WishlistCard.tsx +42 -6
- package/src/listing-card/shared.ts +22 -0
- package/src/listing-card/types.ts +37 -0
- package/src/rating/RatingInput.tsx +214 -0
- package/src/rating/index.ts +9 -1
- package/src/rating/types.ts +33 -0
- package/src/stay-search/DestinationSuggestions.tsx +48 -9
- package/src/stay-search/GuestPicker.tsx +9 -0
- package/src/stay-search/types.ts +20 -0
- package/src/track-list/parts.tsx +1 -20
package/src/app-shell/types.ts
CHANGED
|
@@ -32,8 +32,14 @@ export type AppShellDrawer = 'overlay' | 'reveal';
|
|
|
32
32
|
* - `focus` — a single centred column of `contentWidth` and NO nav: sign-in,
|
|
33
33
|
* onboarding, a reader. It keeps the header, `topBar`, `bottomBar` and
|
|
34
34
|
* `floatingAction` slots, so a sign-up flow still gets its footer CTA.
|
|
35
|
+
* - `canvas` — the content is the SCREEN: one edge-to-edge area with no reading
|
|
36
|
+
* column, no max width and no padding, exactly one viewport tall, and the
|
|
37
|
+
* chrome floats over it. A map, a board, an editor, a photo viewer. Each
|
|
38
|
+
* region keeps its own inset (the nav is still a card with a gutter around
|
|
39
|
+
* it) so only the canvas runs to the edge. Like `split`, it never scrolls the
|
|
40
|
+
* document — the canvas owns its own panning and zooming.
|
|
35
41
|
*/
|
|
36
|
-
export type AppShellVariant = 'dashboard' | 'feed' | 'split' | 'focus';
|
|
42
|
+
export type AppShellVariant = 'dashboard' | 'feed' | 'split' | 'focus' | 'canvas';
|
|
37
43
|
|
|
38
44
|
/** Which single pane a `split` shell shows below `splitFrom`. */
|
|
39
45
|
export type AppShellPane = 'list' | 'detail' | 'info';
|
|
@@ -108,7 +114,11 @@ export interface AppShellProps {
|
|
|
108
114
|
asideWidth?: number;
|
|
109
115
|
/** The breakpoint the aside sits beside the content from. Defaults to `xl` (1280). */
|
|
110
116
|
asideFrom?: AppShellBreakpoint;
|
|
111
|
-
/**
|
|
117
|
+
/**
|
|
118
|
+
* Below `asideFrom`: `stack` (default) renders it after the content, `hidden`
|
|
119
|
+
* drops it. `canvas` always drops it — the screen is the canvas, so there is
|
|
120
|
+
* no column underneath to stack into.
|
|
121
|
+
*/
|
|
112
122
|
asideCollapse?: 'stack' | 'hidden';
|
|
113
123
|
children?: ReactNode;
|
|
114
124
|
/** The content column's max width. Defaults to 1300. */
|
|
@@ -71,6 +71,7 @@ const ContentPanelComponent: React.FC<ContentPanelProps> = ({
|
|
|
71
71
|
children,
|
|
72
72
|
framed,
|
|
73
73
|
framedFrom = 768,
|
|
74
|
+
fill = false,
|
|
74
75
|
surfaceClassName,
|
|
75
76
|
surfaceStyle,
|
|
76
77
|
surfaceColor,
|
|
@@ -97,7 +98,7 @@ const ContentPanelComponent: React.FC<ContentPanelProps> = ({
|
|
|
97
98
|
: framed
|
|
98
99
|
? 'flex-1 overflow-hidden rounded-radius-28 border border-border'
|
|
99
100
|
: 'flex-1';
|
|
100
|
-
const surfaceClass = [surfaceBase, surfaceClassName ?? 'bg-card'].join(' ');
|
|
101
|
+
const surfaceClass = [surfaceBase, fill ? 'min-h-0' : '', surfaceClassName ?? 'bg-card'].filter(Boolean).join(' ');
|
|
101
102
|
// Native has no overlays: the surface itself carries the edge. `none` drops
|
|
102
103
|
// both, `border` keeps the class-drawn hairline, `elevated` adds the lift.
|
|
103
104
|
const chromeStyle =
|
|
@@ -106,7 +107,12 @@ const ContentPanelComponent: React.FC<ContentPanelProps> = ({
|
|
|
106
107
|
: shadow || panelChrome
|
|
107
108
|
? { boxShadow: shadow ?? panelChrome?.shadow }
|
|
108
109
|
: null;
|
|
109
|
-
|
|
110
|
+
// `fill` is the panel's own height contract, and on native it is already
|
|
111
|
+
// met: both boxes are `flex: 1`, so in a bounded parent the panel reaches the
|
|
112
|
+
// bottom and a list inside it scrolls within it. What the prop adds here is
|
|
113
|
+
// `min-h-0`, so a tall child cannot push the panel past the box it was given
|
|
114
|
+
// — the same automatic-minimum-size trap as on web.
|
|
115
|
+
const contentClass = ['flex-1', fill ? 'min-h-0' : '', contentClassName].filter(Boolean).join(' ');
|
|
110
116
|
|
|
111
117
|
return (
|
|
112
118
|
<ContentPanelNestingContext.Provider value={true}>
|
|
@@ -148,6 +148,7 @@ const ContentPanelComponent: React.FC<ContentPanelProps> = ({
|
|
|
148
148
|
children,
|
|
149
149
|
framed,
|
|
150
150
|
framedFrom = 768,
|
|
151
|
+
fill = false,
|
|
151
152
|
surfaceClassName,
|
|
152
153
|
surfaceStyle,
|
|
153
154
|
surfaceColor,
|
|
@@ -157,8 +158,8 @@ const ContentPanelComponent: React.FC<ContentPanelProps> = ({
|
|
|
157
158
|
chrome = 'elevated',
|
|
158
159
|
shadow,
|
|
159
160
|
maskColor,
|
|
160
|
-
overlaySizing = '
|
|
161
|
-
|
|
161
|
+
overlaySizing = 'viewport',
|
|
162
|
+
overlayInset,
|
|
162
163
|
}) => {
|
|
163
164
|
// Dev-only invariant — must run unconditionally (before deriving any
|
|
164
165
|
// mode-specific branch) so the hook order stays stable (rules of hooks).
|
|
@@ -194,6 +195,15 @@ const ContentPanelComponent: React.FC<ContentPanelProps> = ({
|
|
|
194
195
|
: framed
|
|
195
196
|
? 'flex-1 rounded-radius-28 web:overflow-x-clip'
|
|
196
197
|
: 'flex-1';
|
|
198
|
+
// `fill`: the panel takes the height its parent gives it and the CONTENT
|
|
199
|
+
// scrolls inside. `min-h-0` on both boxes is the whole trick — a flex child's
|
|
200
|
+
// automatic minimum size is its content, so without it the panel grows past
|
|
201
|
+
// the box it was told to fit and the scroller never gets a height to scroll
|
|
202
|
+
// against. The overflow goes on the content wrapper, never the surface: the
|
|
203
|
+
// surface is what the frame and the mask are drawn to, and a scrolling
|
|
204
|
+
// surface would scroll the edge with it.
|
|
205
|
+
const fillSurface = fill ? 'min-h-0' : '';
|
|
206
|
+
const fillContent = fill ? 'min-h-0 web:[overflow-y:auto]' : '';
|
|
197
207
|
// `overlaySizing="panel"`: the surface becomes a single-cell CSS Grid so the
|
|
198
208
|
// overlays and the content wrapper below can all be placed in that ONE cell
|
|
199
209
|
// (`web:[grid-area:1/1]`) and each fill it exactly — `minmax(0,1fr)` (not
|
|
@@ -204,32 +214,36 @@ const ContentPanelComponent: React.FC<ContentPanelProps> = ({
|
|
|
204
214
|
const gridStackClass = boundToPanel
|
|
205
215
|
? 'web:grid web:[grid-template-columns:minmax(0,1fr)] web:[grid-template-rows:minmax(0,1fr)]'
|
|
206
216
|
: '';
|
|
207
|
-
const surfaceClass = [surfaceBase, gridStackClass, surfaceClassName ?? 'bg-card'].filter(Boolean).join(' ');
|
|
208
|
-
const contentClass = [contentBase, boundToPanel ? 'web:[grid-area:1/1]' : '', contentClassName]
|
|
217
|
+
const surfaceClass = [surfaceBase, fillSurface, gridStackClass, surfaceClassName ?? 'bg-card'].filter(Boolean).join(' ');
|
|
218
|
+
const contentClass = [contentBase, fillContent, boundToPanel ? 'web:[grid-area:1/1]' : '', contentClassName]
|
|
209
219
|
.filter(Boolean)
|
|
210
220
|
.join(' ');
|
|
211
221
|
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
//
|
|
218
|
-
//
|
|
219
|
-
// has no such constraint.
|
|
220
|
-
// panel
|
|
221
|
-
//
|
|
222
|
+
// The viewport-mode overlays are sized and positioned from literal Tailwind
|
|
223
|
+
// classes (`top-2`, `h-[calc(100dvh-16px)]`, the matching negative
|
|
224
|
+
// `margin-bottom`) because those values are constants known at build time.
|
|
225
|
+
// An INSET is a runtime number — a shell's gutter, a measured header — which
|
|
226
|
+
// a Tailwind arbitrary class cannot express (content-scanning needs the
|
|
227
|
+
// literal string in source, not a value computed later), so a non-default
|
|
228
|
+
// inset shifts the same three properties through inline `style` instead,
|
|
229
|
+
// which has no such constraint. The default (8/8) leaves the classes standing
|
|
230
|
+
// alone, and `panel` mode ignores the inset entirely — that mode already
|
|
231
|
+
// starts at the panel's own box.
|
|
232
|
+
//
|
|
222
233
|
// RN's `ViewStyle.height`/`marginBottom` types only accept a number or a
|
|
223
|
-
// `${number}%` string
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
//
|
|
227
|
-
|
|
228
|
-
const
|
|
234
|
+
// `${number}%` string, not an arbitrary CSS `calc()`, because most of that
|
|
235
|
+
// type is shared with native where `calc()` does not exist. This file is
|
|
236
|
+
// web-only, where it is a real value react-native-web passes straight
|
|
237
|
+
// through.
|
|
238
|
+
const insetTop = typeof overlayInset === 'number' ? overlayInset : (overlayInset?.top ?? PANEL_TOP_INSET);
|
|
239
|
+
const insetBottom =
|
|
240
|
+
typeof overlayInset === 'number' ? overlayInset : (overlayInset?.bottom ?? PANEL_BOTTOM_INSET);
|
|
241
|
+
const customInset = insetTop !== PANEL_TOP_INSET || insetBottom !== PANEL_BOTTOM_INSET;
|
|
242
|
+
const insetStyle = !boundToPanel && customInset
|
|
229
243
|
? ({
|
|
230
|
-
top:
|
|
231
|
-
height: `calc(100dvh - ${
|
|
232
|
-
marginBottom: `calc(-100dvh + ${
|
|
244
|
+
top: insetTop,
|
|
245
|
+
height: `calc(100dvh - ${insetTop + insetBottom}px)`,
|
|
246
|
+
marginBottom: `calc(-100dvh + ${insetTop + insetBottom}px)`,
|
|
233
247
|
} as unknown as ViewStyle)
|
|
234
248
|
: undefined;
|
|
235
249
|
|
|
@@ -261,7 +275,7 @@ const ContentPanelComponent: React.FC<ContentPanelProps> = ({
|
|
|
261
275
|
? `web:sticky web:top-2 z-30 h-[calc(100dvh-16px)] w-full rounded-radius-28 ${bp.overlayHidden} web:[margin-bottom:calc(-100dvh+16px)] web:[clip-path:inset(-12px)]`
|
|
262
276
|
: 'web:sticky web:top-2 z-30 h-[calc(100dvh-16px)] w-full rounded-radius-28 web:[margin-bottom:calc(-100dvh+16px)] web:[clip-path:inset(-12px)]'
|
|
263
277
|
}
|
|
264
|
-
style={{ ...
|
|
278
|
+
style={{ ...insetStyle, boxShadow: `0 0 0 ${GUTTER_MASK_SPREAD}px ${maskColor ?? colors.background}` }}
|
|
265
279
|
/>
|
|
266
280
|
)}
|
|
267
281
|
{/* (2) Border-frame overlay — one continuous rounded border, above all.
|
|
@@ -274,7 +288,7 @@ const ContentPanelComponent: React.FC<ContentPanelProps> = ({
|
|
|
274
288
|
// The shadow rides the SAME element as the hairline, so the lift and
|
|
275
289
|
// the edge can never disagree about where the panel ends. It paints
|
|
276
290
|
// outward, into the gutter the page background shows.
|
|
277
|
-
style={[
|
|
291
|
+
style={[insetStyle, chrome === 'elevated' && (shadow || panelChrome)
|
|
278
292
|
? { boxShadow: shadow ?? panelChrome?.shadow }
|
|
279
293
|
: null]}
|
|
280
294
|
className={
|
|
@@ -52,6 +52,28 @@ export interface ContentPanelProps {
|
|
|
52
52
|
* For an app that wants its own elevation without giving up the hairline.
|
|
53
53
|
*/
|
|
54
54
|
shadow?: string;
|
|
55
|
+
/**
|
|
56
|
+
* The panel is the HEIGHT OF THE SPACE IT IS GIVEN, and its content scrolls
|
|
57
|
+
* inside it — instead of the panel growing with its content while the page
|
|
58
|
+
* scrolls underneath.
|
|
59
|
+
*
|
|
60
|
+
* This is what makes a framed column read as a panel: it reaches the bottom
|
|
61
|
+
* of the screen and closes there, the way the rail beside it does. Without
|
|
62
|
+
* it, a feed taller than the window gives a panel whose top edge you see and
|
|
63
|
+
* whose bottom edge is somewhere past the fold — an open-ended column, not a
|
|
64
|
+
* surface.
|
|
65
|
+
*
|
|
66
|
+
* It needs a parent that BOUNDS it (a flex column with a real height): an
|
|
67
|
+
* `AppShell` with `scroll="fixed"` or `scroll="container"`, or any screen
|
|
68
|
+
* that is `flex: 1`. In a document-scrolled page there is no height to fill
|
|
69
|
+
* and the panel keeps growing with its content — that page wants
|
|
70
|
+
* `scroll="document"`, not this prop.
|
|
71
|
+
*
|
|
72
|
+
* On web the content wrapper becomes the scroller. On native the panel and
|
|
73
|
+
* its content are already `flex: 1`, so a `FlatList`/`ScrollView` inside
|
|
74
|
+
* fills the panel and scrolls within it.
|
|
75
|
+
*/
|
|
76
|
+
fill?: boolean;
|
|
55
77
|
/** Override the surface background utility (defaults to `bg-card`). */
|
|
56
78
|
surfaceClassName?: string;
|
|
57
79
|
surfaceStyle?: StyleProp<ViewStyle>;
|
|
@@ -89,52 +111,38 @@ export interface ContentPanelProps {
|
|
|
89
111
|
*/
|
|
90
112
|
maskColor?: string;
|
|
91
113
|
/**
|
|
92
|
-
* How the WEB overlays (bleed-mask
|
|
93
|
-
* native (there are no overlays to size).
|
|
94
|
-
* - `'viewport'` (DEFAULT) — sized to the dynamic viewport height (`100dvh`)
|
|
95
|
-
* and `position: sticky`-pinned, so they read as a constant frame around
|
|
96
|
-
* the visible slice of a panel whose own DOM height can exceed one screen
|
|
97
|
-
* — the document-scroll case (e.g. a feed whose page grows with content).
|
|
98
|
-
* Requires the panel to sit in a document that actually scrolls; see
|
|
99
|
-
* `ContentPanel.web.tsx`'s doc comment.
|
|
100
|
-
* - `'panel'` — sized to the panel's own real box via CSS Grid layer-
|
|
101
|
-
* stacking (the overlays and the content wrapper all occupy the same
|
|
102
|
-
* grid cell) instead of viewport math. The right mode for a consumer
|
|
103
|
-
* whose own shell already bounds the panel's height (no document
|
|
104
|
-
* scroll — a fixed app shell with its own internal scroll containers),
|
|
105
|
-
* where anything the consumer places outside the panel (e.g. a header
|
|
106
|
-
* above it) must never be overlapped by an overlay sized to the whole
|
|
107
|
-
* viewport.
|
|
108
|
-
*/
|
|
109
|
-
/**
|
|
110
|
-
* DEFAULT `'panel'`. The edge describes the panel's OWN box, so it scrolls
|
|
111
|
-
* with the content like any other border: on a page taller than the screen
|
|
112
|
-
* you see the two side edges while scrolling and the rounded corners at the
|
|
113
|
-
* ends, which is what a long framed column looks like.
|
|
114
|
+
* How the WEB overlays (the bleed-mask and the border frame) size themselves.
|
|
115
|
+
* No-op on native (there are no overlays to size).
|
|
114
116
|
*
|
|
115
|
-
* `'viewport'`
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
117
|
+
* - `'viewport'` (DEFAULT) — the frame is a SCREEN-TALL sticky rectangle:
|
|
118
|
+
* `100dvh` minus the insets, pinned at `overlayInset`. The panel's own box
|
|
119
|
+
* grows with its content and the document scrolls it, while the edge stays
|
|
120
|
+
* where the screen is — so the panel reads as a surface that occupies the
|
|
121
|
+
* window from top to bottom and the content moves inside it. This is the
|
|
122
|
+
* mode for a page that scrolls the document.
|
|
123
|
+
* - `'panel'` — the overlays are the panel's OWN box, through CSS Grid
|
|
124
|
+
* layer-stacking (the overlays and the content wrapper share one grid
|
|
125
|
+
* cell). The right mode when the panel's box already IS the visible area
|
|
126
|
+
* (`fill`, inside a shell that bounds it) or when something the consumer
|
|
127
|
+
* placed outside the panel — a header above it — must never be painted
|
|
128
|
+
* over by an overlay sized to the whole viewport.
|
|
121
129
|
*/
|
|
122
130
|
overlaySizing?: 'viewport' | 'panel';
|
|
123
131
|
/**
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
132
|
+
* The gap between the VIEWPORT's edge and the sticky frame, in `'viewport'`
|
|
133
|
+
* mode. Default 8.
|
|
134
|
+
*
|
|
135
|
+
* **It must equal the distance from the top of the screen to the panel's own
|
|
136
|
+
* top edge.** Sticky positioning leaves an element at its static position
|
|
137
|
+
* until scrolling would push it past the inset — so a panel that starts 16px
|
|
138
|
+
* down the page with an 8px inset draws its frame at 16, then SNAPS it to 8
|
|
139
|
+
* on the first scroll. That 8px jump is the whole bug, and it reads as the
|
|
140
|
+
* panel breathing. A shell passes its own gutter here.
|
|
129
141
|
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
* where the panel visually starts without moving the panel's own DOM
|
|
135
|
-
* position, so the overlays would still read from viewport 0 and paint
|
|
136
|
-
* over that chrome. This shifts their `top` down and shrinks their height
|
|
137
|
-
* by the same amount, so they begin exactly where the panel visually does.
|
|
142
|
+
* Takes a pair when the top and the bottom differ — persistent chrome above
|
|
143
|
+
* the panel (a sticky header the consumer keeps outside it) shifts where the
|
|
144
|
+
* panel visually starts without moving its DOM position, so the frame has to
|
|
145
|
+
* start lower without also ending higher.
|
|
138
146
|
*/
|
|
139
|
-
|
|
147
|
+
overlayInset?: number | { top: number; bottom: number };
|
|
140
148
|
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import React, { memo, useEffect, useMemo, useState } from 'react';
|
|
2
|
+
import { Platform, TextInput, type TextStyle } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { MENU_FONT_FAMILY } from '../floating/menu-type';
|
|
5
|
+
import { DISABLED_OPACITY } from '../styles/tokens';
|
|
6
|
+
import type { WebCssStyle } from '../styles/web-view-style';
|
|
7
|
+
import { useTheme } from '../theme/use-theme';
|
|
8
|
+
import { TYPE_SCALE } from '../typography';
|
|
9
|
+
import { resolveCalendarPalette } from './palette';
|
|
10
|
+
import { formatTime, parseTime, snapTime, stepTime } from './time';
|
|
11
|
+
import type { TimeFieldProps, TimeFieldSize } from './types';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A time of day, typed. `DatePicker` is the day; this is the hour and minute,
|
|
15
|
+
* and it is a FIELD rather than a fourth popover — a viewing at 18:30 is four
|
|
16
|
+
* keystrokes, and a popover is the wrong instrument for four keystrokes.
|
|
17
|
+
*
|
|
18
|
+
* width height text step arrows
|
|
19
|
+
* small 96 32 body-2-medium yes
|
|
20
|
+
* medium 104 38 body-medium yes
|
|
21
|
+
*
|
|
22
|
+
* Geometry, palette and focus behaviour are `DateChipInput`'s — the
|
|
23
|
+
* `DD/MM/YYYY` chip inside the pickers — so a date and a time typed beside each
|
|
24
|
+
* other line up: 1px `chipBorder` darkening to `chipBorderFocused` on focus
|
|
25
|
+
* (the border IS the focus indicator, `outline-none`), radius 10, shadow-xs,
|
|
26
|
+
* the panel fill.
|
|
27
|
+
*
|
|
28
|
+
* It keeps its own DRAFT while typing, so the text is not reformatted under the
|
|
29
|
+
* cursor, and commits on blur or submit; Escape reverts. A draft that does not
|
|
30
|
+
* parse reverts too, rather than reporting an error the field has no room to
|
|
31
|
+
* draw — `parseTime` is forgiving enough (`9`, `930`, `9.30`, `9pm`, `21:30`)
|
|
32
|
+
* that the remaining failures really are not times.
|
|
33
|
+
*
|
|
34
|
+
* ArrowUp / ArrowDown move by `step` and commit immediately, clamped at `min`
|
|
35
|
+
* and `max` rather than wrapping. They are a convenience on a text box, not a
|
|
36
|
+
* `spinbutton`: the control is a text field, it announces itself as one, and
|
|
37
|
+
* every value it can reach can also be typed. Its NAME is
|
|
38
|
+
* `accessibilityLabel` — required, because `"--:--"` names nothing.
|
|
39
|
+
*
|
|
40
|
+
* The VALUE is always 24h `"HH:mm"`, whatever `hourFormat` draws, so it drops
|
|
41
|
+
* straight into `MeetingScheduler`'s `timeSlots` and needs no locale from the
|
|
42
|
+
* caller.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
const SIZE_CONFIG: Record<TimeFieldSize, { width: number; height: number; type: 'body-medium' | 'body-2-medium' }> = {
|
|
46
|
+
small: { width: 96, height: 32, type: 'body-2-medium' },
|
|
47
|
+
medium: { width: 104, height: 38, type: 'body-medium' },
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/** `transition-colors duration-100 ease-out` on the border. Web only. */
|
|
51
|
+
const FIELD_TRANSITION: WebCssStyle | null =
|
|
52
|
+
Platform.OS === 'web'
|
|
53
|
+
? { transitionProperty: 'border-color', transitionDuration: '100ms', transitionTimingFunction: 'ease-out' }
|
|
54
|
+
: null;
|
|
55
|
+
|
|
56
|
+
function TimeFieldComponent({
|
|
57
|
+
value,
|
|
58
|
+
onChange,
|
|
59
|
+
min,
|
|
60
|
+
max,
|
|
61
|
+
step = 1,
|
|
62
|
+
hourFormat = '24h',
|
|
63
|
+
size = 'medium',
|
|
64
|
+
disabled = false,
|
|
65
|
+
accessibilityLabel,
|
|
66
|
+
placeholder,
|
|
67
|
+
width,
|
|
68
|
+
style,
|
|
69
|
+
testID,
|
|
70
|
+
}: TimeFieldProps) {
|
|
71
|
+
const theme = useTheme();
|
|
72
|
+
const palette = useMemo(() => resolveCalendarPalette(theme), [theme]);
|
|
73
|
+
const config = SIZE_CONFIG[size];
|
|
74
|
+
|
|
75
|
+
const shown = value ? formatTime(value, hourFormat) : '';
|
|
76
|
+
const [text, setText] = useState(shown);
|
|
77
|
+
const [focused, setFocused] = useState(false);
|
|
78
|
+
|
|
79
|
+
// The committed value is the source of truth; a change from outside (or a
|
|
80
|
+
// format flip) rewrites the draft.
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
setText(shown);
|
|
83
|
+
}, [shown]);
|
|
84
|
+
|
|
85
|
+
const commit = (draft: string) => {
|
|
86
|
+
if (draft.trim() === '') {
|
|
87
|
+
setText('');
|
|
88
|
+
if (value !== null) onChange(null);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const parsed = parseTime(draft, hourFormat);
|
|
92
|
+
const next = parsed ? snapTime(parsed, { step, min, max }) : null;
|
|
93
|
+
if (!next) {
|
|
94
|
+
setText(shown);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
setText(formatTime(next, hourFormat));
|
|
98
|
+
if (next !== value) onChange(next);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const nudge = (direction: 1 | -1) => {
|
|
102
|
+
const next = stepTime(value, direction, { step, min, max });
|
|
103
|
+
if (!next) return;
|
|
104
|
+
setText(formatTime(next, hourFormat));
|
|
105
|
+
if (next !== value) onChange(next);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const fieldStyle: TextStyle = {
|
|
109
|
+
width: width ?? config.width,
|
|
110
|
+
height: config.height,
|
|
111
|
+
paddingLeft: 8,
|
|
112
|
+
paddingRight: 8,
|
|
113
|
+
borderWidth: 1,
|
|
114
|
+
borderRadius: 10,
|
|
115
|
+
borderColor: focused ? palette.chipBorderFocused : palette.chipBorder,
|
|
116
|
+
backgroundColor: palette.panel,
|
|
117
|
+
boxShadow: palette.shadowXs,
|
|
118
|
+
color: disabled ? palette.disabledText : palette.text,
|
|
119
|
+
opacity: disabled ? DISABLED_OPACITY : 1,
|
|
120
|
+
// A `TextInput` inherits no face.
|
|
121
|
+
...TYPE_SCALE[config.type],
|
|
122
|
+
...MENU_FONT_FAMILY,
|
|
123
|
+
// `outline-none`: the border change is the focus indicator.
|
|
124
|
+
outlineWidth: 0,
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<TextInput
|
|
129
|
+
value={text}
|
|
130
|
+
editable={!disabled}
|
|
131
|
+
onChangeText={setText}
|
|
132
|
+
onFocus={() => setFocused(true)}
|
|
133
|
+
onBlur={() => {
|
|
134
|
+
setFocused(false);
|
|
135
|
+
commit(text);
|
|
136
|
+
}}
|
|
137
|
+
onSubmitEditing={() => commit(text)}
|
|
138
|
+
onKeyPress={(event) => {
|
|
139
|
+
const key = event.nativeEvent.key;
|
|
140
|
+
if (key === 'Escape') {
|
|
141
|
+
setText(shown);
|
|
142
|
+
} else if (key === 'ArrowUp') {
|
|
143
|
+
(event as unknown as { preventDefault?: () => void }).preventDefault?.();
|
|
144
|
+
nudge(1);
|
|
145
|
+
} else if (key === 'ArrowDown') {
|
|
146
|
+
(event as unknown as { preventDefault?: () => void }).preventDefault?.();
|
|
147
|
+
nudge(-1);
|
|
148
|
+
}
|
|
149
|
+
}}
|
|
150
|
+
placeholder={placeholder ?? (hourFormat === '12h' ? '--:-- --' : '--:--')}
|
|
151
|
+
placeholderTextColor={palette.secondaryText}
|
|
152
|
+
inputMode={hourFormat === '12h' ? 'text' : 'numeric'}
|
|
153
|
+
accessibilityLabel={accessibilityLabel}
|
|
154
|
+
aria-disabled={disabled || undefined}
|
|
155
|
+
style={[FIELD_TRANSITION, fieldStyle, style]}
|
|
156
|
+
testID={testID}
|
|
157
|
+
/>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export const TimeField = memo(TimeFieldComponent);
|
|
162
|
+
TimeField.displayName = 'TimeField';
|
package/src/date-picker/index.ts
CHANGED
|
@@ -2,6 +2,9 @@ export { Calendar, RangeCalendar } from './Calendar';
|
|
|
2
2
|
export { DatePicker } from './DatePicker';
|
|
3
3
|
export { DateRangePicker } from './DateRangePicker';
|
|
4
4
|
export { MeetingScheduler } from './MeetingScheduler';
|
|
5
|
+
export { TimeField } from './TimeField';
|
|
6
|
+
// `time.ts`'s pure helpers stay internal, as `calendar-grid.ts`'s already do:
|
|
7
|
+
// `formatTime`/`parseTime` are names the root barrel cannot afford.
|
|
5
8
|
|
|
6
9
|
export type {
|
|
7
10
|
CalendarConstraintProps,
|
|
@@ -9,6 +12,7 @@ export type {
|
|
|
9
12
|
DatePickerProps,
|
|
10
13
|
DateRange,
|
|
11
14
|
DateRangePickerProps,
|
|
15
|
+
HourFormat,
|
|
12
16
|
MeetingSchedulerDetails,
|
|
13
17
|
MeetingSchedulerHost,
|
|
14
18
|
MeetingSchedulerHourFormat,
|
|
@@ -16,5 +20,7 @@ export type {
|
|
|
16
20
|
MeetingSchedulerProps,
|
|
17
21
|
MeetingSchedulerValue,
|
|
18
22
|
RangeCalendarProps,
|
|
23
|
+
TimeFieldProps,
|
|
24
|
+
TimeFieldSize,
|
|
19
25
|
WeekStart,
|
|
20
26
|
} from './types';
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import type { HourFormat } from './types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Time of day as the family already spells it: a 24-hour `"HH:mm"` string.
|
|
5
|
+
* `MeetingScheduler`'s `timeSlots` and `MeetingSchedulerValue.time` are the same
|
|
6
|
+
* shape, so a `TimeField` value drops straight into one.
|
|
7
|
+
*
|
|
8
|
+
* Everything here is pure, exported, and where the parsing rules live — a field
|
|
9
|
+
* that keeps its own draft cannot be tested through the DOM for every spelling
|
|
10
|
+
* somebody might type.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Minutes since midnight, `0..1439`. */
|
|
14
|
+
export type Minutes = number;
|
|
15
|
+
|
|
16
|
+
const HH_MM = /^([01]?\d|2[0-3]):([0-5]\d)$/;
|
|
17
|
+
|
|
18
|
+
/** `"14:30"` → 870. `null` for anything that is not a 24h `HH:mm`. */
|
|
19
|
+
export function toMinutes(value: string): Minutes | null {
|
|
20
|
+
const match = HH_MM.exec(value.trim());
|
|
21
|
+
if (!match) return null;
|
|
22
|
+
return Number(match[1]) * 60 + Number(match[2]);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** 870 → `"14:30"`. Wraps within the day, so 1440 is `"00:00"`. */
|
|
26
|
+
export function fromMinutes(minutes: Minutes): string {
|
|
27
|
+
const wrapped = ((Math.round(minutes) % 1440) + 1440) % 1440;
|
|
28
|
+
const h = Math.floor(wrapped / 60);
|
|
29
|
+
const m = wrapped % 60;
|
|
30
|
+
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** `"14:30"` → `"14:30"` (24h) or `"2:30 PM"` (12h). */
|
|
34
|
+
export function formatTime(value: string, hourFormat: HourFormat): string {
|
|
35
|
+
const minutes = toMinutes(value);
|
|
36
|
+
if (minutes == null) return value;
|
|
37
|
+
if (hourFormat === '24h') return fromMinutes(minutes);
|
|
38
|
+
const h = Math.floor(minutes / 60);
|
|
39
|
+
const m = minutes % 60;
|
|
40
|
+
const period = h < 12 ? 'AM' : 'PM';
|
|
41
|
+
const hour12 = h % 12 === 0 ? 12 : h % 12;
|
|
42
|
+
return `${hour12}:${String(m).padStart(2, '0')} ${period}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* What somebody typed → `"HH:mm"`, or `null` when it is not a time.
|
|
47
|
+
*
|
|
48
|
+
* Deliberately forgiving, because a field that only accepts what it printed is
|
|
49
|
+
* a field people fight: `9`, `930`, `9.30`, `9:3`, `0930`, `9 30`, `9pm`,
|
|
50
|
+
* `9:30 p.m.` and `21:30` all parse. A bare number under 24 is an HOUR (`9` is
|
|
51
|
+
* 09:00), four digits are `HHMM` (`0930`); `AM`/`PM` decides the half of the
|
|
52
|
+
* day whatever `hourFormat` says, because somebody who typed `pm` means pm.
|
|
53
|
+
* Without a marker, 24h reads the hour as given and 12h keeps `1..12` in the
|
|
54
|
+
* morning — the value is still `"HH:mm"`, so a caller never has to know which.
|
|
55
|
+
*/
|
|
56
|
+
export function parseTime(text: string, hourFormat: HourFormat = '24h'): string | null {
|
|
57
|
+
const raw = text.trim().toLowerCase();
|
|
58
|
+
if (raw === '') return null;
|
|
59
|
+
const period = /(^|[^a-z])(a\.?m\.?|p\.?m\.?)\s*$/.exec(raw);
|
|
60
|
+
const meridiem = period ? (period[2]!.startsWith('a') ? 'am' : 'pm') : null;
|
|
61
|
+
const digits = (meridiem ? raw.slice(0, period!.index + period![1]!.length) : raw).trim();
|
|
62
|
+
|
|
63
|
+
let hour: number;
|
|
64
|
+
let minute: number;
|
|
65
|
+
const separated = /^(\d{1,2})\s*[:.\s]\s*(\d{1,2})$/.exec(digits);
|
|
66
|
+
if (separated) {
|
|
67
|
+
hour = Number(separated[1]);
|
|
68
|
+
minute = Number(separated[2]!.padEnd(2, '0'));
|
|
69
|
+
} else if (/^\d{3,4}$/.test(digits)) {
|
|
70
|
+
hour = Number(digits.slice(0, digits.length - 2));
|
|
71
|
+
minute = Number(digits.slice(-2));
|
|
72
|
+
} else if (/^\d{1,2}$/.test(digits)) {
|
|
73
|
+
hour = Number(digits);
|
|
74
|
+
minute = 0;
|
|
75
|
+
} else {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (minute > 59) return null;
|
|
80
|
+
if (meridiem) {
|
|
81
|
+
if (hour < 1 || hour > 12) return null;
|
|
82
|
+
hour = meridiem === 'pm' ? (hour % 12) + 12 : hour % 12;
|
|
83
|
+
} else if (hour > 23) {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
return fromMinutes(hour * 60 + minute);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The nearest multiple of `step` minutes (or the next one up/down, per
|
|
91
|
+
* `round`), at or after `min` and at or before `max`. `step` counts from
|
|
92
|
+
* MIDNIGHT, so 15 gives :00 :15 :30 :45 whatever the bounds are — a step
|
|
93
|
+
* measured from `min` would put the round times off the grid the moment a shop
|
|
94
|
+
* opened at 09:10. Never crosses the end of the day: 23:59 rounded up to a
|
|
95
|
+
* 30-minute grid stays inside it rather than wrapping to the other end.
|
|
96
|
+
*/
|
|
97
|
+
export function snapTime(
|
|
98
|
+
value: string,
|
|
99
|
+
{
|
|
100
|
+
step = 1,
|
|
101
|
+
min,
|
|
102
|
+
max,
|
|
103
|
+
round = 'nearest',
|
|
104
|
+
}: { step?: number; min?: string; max?: string; round?: 'nearest' | 'up' | 'down' } = {},
|
|
105
|
+
): string | null {
|
|
106
|
+
const minutes = toMinutes(value);
|
|
107
|
+
if (minutes == null) return null;
|
|
108
|
+
const size = Math.max(1, Math.round(step));
|
|
109
|
+
const scaled = minutes / size;
|
|
110
|
+
let snapped =
|
|
111
|
+
(round === 'up' ? Math.ceil(scaled) : round === 'down' ? Math.floor(scaled) : Math.round(scaled)) *
|
|
112
|
+
size;
|
|
113
|
+
const low = min ? toMinutes(min) : null;
|
|
114
|
+
const high = max ? toMinutes(max) : null;
|
|
115
|
+
if (low != null && high != null && low > high) return null;
|
|
116
|
+
if (low != null && snapped < low) snapped = low;
|
|
117
|
+
if (high != null && snapped > high) snapped = high;
|
|
118
|
+
// Never past the end of the day: rounding 23:59 up to a 30-minute grid lands
|
|
119
|
+
// on 24:00, which `fromMinutes` would wrap to midnight — the other end.
|
|
120
|
+
if (snapped > 1439) snapped = high ?? 1439;
|
|
121
|
+
if (snapped < 0) snapped = low ?? 0;
|
|
122
|
+
return fromMinutes(snapped);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* One arrow press: `direction * step` minutes from `value`, or the first time
|
|
127
|
+
* inside the bounds when there is no value yet. Clamps at the bounds rather
|
|
128
|
+
* than wrapping — a wrap past `max` back to `min` reads as the field refusing
|
|
129
|
+
* the key.
|
|
130
|
+
*/
|
|
131
|
+
export function stepTime(
|
|
132
|
+
value: string | null,
|
|
133
|
+
direction: 1 | -1,
|
|
134
|
+
{ step = 1, min, max }: { step?: number; min?: string; max?: string } = {},
|
|
135
|
+
): string | null {
|
|
136
|
+
const size = Math.max(1, Math.round(step));
|
|
137
|
+
const low = min ? toMinutes(min) : null;
|
|
138
|
+
const high = max ? toMinutes(max) : null;
|
|
139
|
+
if (low != null && high != null && low > high) return null;
|
|
140
|
+
const current = value ? toMinutes(value) : null;
|
|
141
|
+
if (current == null) {
|
|
142
|
+
// Into the range, not past it: up from the earliest allowed time rounds UP
|
|
143
|
+
// onto the grid, down from the latest rounds DOWN.
|
|
144
|
+
const start = direction === 1 ? (low ?? 0) : (high ?? 1439);
|
|
145
|
+
return snapTime(fromMinutes(start), {
|
|
146
|
+
step: size,
|
|
147
|
+
min,
|
|
148
|
+
max,
|
|
149
|
+
round: direction === 1 ? 'up' : 'down',
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
let next = current + direction * size;
|
|
153
|
+
if (low != null && next < low) next = low;
|
|
154
|
+
if (high != null && next > high) next = high;
|
|
155
|
+
if (next < 0) next = 0;
|
|
156
|
+
if (next > 1439) next = 1439;
|
|
157
|
+
return fromMinutes(next);
|
|
158
|
+
}
|