@oxy.so/bloom 2.11.0 → 2.12.1
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/message-bubble/MessageBubble.js +16 -1
- package/lib/commonjs/message-bubble/MessageBubble.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/message-bubble/MessageBubble.js +16 -1
- package/lib/module/message-bubble/MessageBubble.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/message-bubble/MessageBubble.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/message-bubble/MessageBubble.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/message-bubble/MessageBubble.tsx +21 -1
- 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
|
@@ -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
|
+
}
|
package/src/date-picker/types.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { StyleProp, ViewStyle } from 'react-native';
|
|
1
|
+
import type { StyleProp, TextStyle, ViewStyle } from 'react-native';
|
|
2
2
|
|
|
3
3
|
/** An inclusive range of calendar days. Both ends are local-midnight `Date`s. */
|
|
4
4
|
export interface DateRange {
|
|
@@ -126,8 +126,14 @@ export interface MeetingSchedulerValue {
|
|
|
126
126
|
time: string | null;
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
-
/**
|
|
130
|
-
|
|
129
|
+
/**
|
|
130
|
+
* How a time of day is drawn. The VALUE is always a 24h `"HH:mm"` string —
|
|
131
|
+
* this decides only what the reader sees.
|
|
132
|
+
*/
|
|
133
|
+
export type HourFormat = '12h' | '24h';
|
|
134
|
+
|
|
135
|
+
/** The hour-format toggle. An alias of {@link HourFormat}. */
|
|
136
|
+
export type MeetingSchedulerHourFormat = HourFormat;
|
|
131
137
|
|
|
132
138
|
/** Fixed strings, overridable for localisation. */
|
|
133
139
|
export interface MeetingSchedulerLabels {
|
|
@@ -169,3 +175,46 @@ export interface MeetingSchedulerProps extends CalendarConstraintProps {
|
|
|
169
175
|
style?: StyleProp<ViewStyle>;
|
|
170
176
|
testID?: string;
|
|
171
177
|
}
|
|
178
|
+
|
|
179
|
+
/** `small` is a 32-tall field with body-2 text; `medium` (default) 38 with body. */
|
|
180
|
+
export type TimeFieldSize = 'small' | 'medium';
|
|
181
|
+
|
|
182
|
+
export interface TimeFieldProps {
|
|
183
|
+
/**
|
|
184
|
+
* The time as a 24h `"HH:mm"` string, or `null` while the field is empty.
|
|
185
|
+
* Fully controlled. The same shape `MeetingScheduler` uses, so a value drops
|
|
186
|
+
* straight into one of its `timeSlots`.
|
|
187
|
+
*/
|
|
188
|
+
value: string | null;
|
|
189
|
+
/**
|
|
190
|
+
* Called with the committed time — on blur, on submit, or on an arrow key.
|
|
191
|
+
* `null` when the field has been emptied. Never called while typing.
|
|
192
|
+
*/
|
|
193
|
+
onChange: (value: string | null) => void;
|
|
194
|
+
/** Earliest time, 24h `"HH:mm"`. A typed time before it is pulled up to it. */
|
|
195
|
+
min?: string;
|
|
196
|
+
/** Latest time, 24h `"HH:mm"`. A typed time after it is pulled back to it. */
|
|
197
|
+
max?: string;
|
|
198
|
+
/**
|
|
199
|
+
* Minutes one arrow press moves, and the grid a typed time snaps to. Default
|
|
200
|
+
* `1` (no snapping); pass `15` or `30` for a booking grid. Counted from
|
|
201
|
+
* midnight, so 15 always means :00 :15 :30 :45.
|
|
202
|
+
*/
|
|
203
|
+
step?: number;
|
|
204
|
+
/** How the time is DRAWN. Default `'24h'`; the value stays 24h either way. */
|
|
205
|
+
hourFormat?: HourFormat;
|
|
206
|
+
/** Default `medium`. */
|
|
207
|
+
size?: TimeFieldSize;
|
|
208
|
+
disabled?: boolean;
|
|
209
|
+
/**
|
|
210
|
+
* The NAME of the time ("Viewing time", "Check-in"). Required: the field
|
|
211
|
+
* draws no label of its own, and `"--:--"` names nothing.
|
|
212
|
+
*/
|
|
213
|
+
accessibilityLabel: string;
|
|
214
|
+
/** The text while empty. Default `"--:--"`, or `"--:-- --"` in 12h. */
|
|
215
|
+
placeholder?: string;
|
|
216
|
+
/** Fixed width. Default: 104 at `medium`, 96 at `small`. */
|
|
217
|
+
width?: number;
|
|
218
|
+
style?: StyleProp<TextStyle>;
|
|
219
|
+
testID?: string;
|
|
220
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -292,8 +292,15 @@ export type { BottomSheetRef, BottomSheetProps } from './bottom-sheet';
|
|
|
292
292
|
// Data display
|
|
293
293
|
export * from './card';
|
|
294
294
|
export * from './badge';
|
|
295
|
-
export { Rating, RatingBar } from './rating';
|
|
296
|
-
export type {
|
|
295
|
+
export { Rating, RatingBar, RatingInput } from './rating';
|
|
296
|
+
export type {
|
|
297
|
+
RatingBarProps,
|
|
298
|
+
RatingCountStyle,
|
|
299
|
+
RatingInputProps,
|
|
300
|
+
RatingInputSize,
|
|
301
|
+
RatingProps,
|
|
302
|
+
RatingSize,
|
|
303
|
+
} from './rating';
|
|
297
304
|
export {
|
|
298
305
|
ExplicitBadge,
|
|
299
306
|
formatDuration,
|
package/src/index.web.ts
CHANGED
|
@@ -297,8 +297,15 @@ export type { BottomSheetRef, BottomSheetProps } from './bottom-sheet/index.web'
|
|
|
297
297
|
// Data display
|
|
298
298
|
export * from './card';
|
|
299
299
|
export * from './badge';
|
|
300
|
-
export { Rating, RatingBar } from './rating';
|
|
301
|
-
export type {
|
|
300
|
+
export { Rating, RatingBar, RatingInput } from './rating';
|
|
301
|
+
export type {
|
|
302
|
+
RatingBarProps,
|
|
303
|
+
RatingCountStyle,
|
|
304
|
+
RatingInputProps,
|
|
305
|
+
RatingInputSize,
|
|
306
|
+
RatingProps,
|
|
307
|
+
RatingSize,
|
|
308
|
+
} from './rating';
|
|
302
309
|
export {
|
|
303
310
|
ExplicitBadge,
|
|
304
311
|
formatDuration,
|
|
@@ -48,6 +48,7 @@ import {
|
|
|
48
48
|
LISTING_CARD_STYLE_ID,
|
|
49
49
|
PHOTO_ASPECT_RATIO,
|
|
50
50
|
PHOTO_RADIUS,
|
|
51
|
+
PHOTO_ZOOM_SCALE,
|
|
51
52
|
resolveListingCardPaint,
|
|
52
53
|
locationText,
|
|
53
54
|
resolvePhoto,
|
|
@@ -91,6 +92,21 @@ import type { ListingCardLayout, ListingCardProps } from './types';
|
|
|
91
92
|
*
|
|
92
93
|
* The overlay repeats the photo's geometry (the same width share and aspect
|
|
93
94
|
* ratio) rather than measuring it, so it is right on the first frame.
|
|
95
|
+
*
|
|
96
|
+
* `hoverZoom` scales the photo to {@link PHOTO_ZOOM_SCALE} while a pointer is
|
|
97
|
+
* over the card — a CSS transform on the photo inside the clipping tile, WEB
|
|
98
|
+
* ONLY (`@media (any-hover: hover)`, so a touch screen that reports a hover it
|
|
99
|
+
* cannot deliver is excluded) and nothing at all under reduced motion. It is
|
|
100
|
+
* off by default: a card that grows under the pointer is a decision an app
|
|
101
|
+
* makes, not one Bloom makes for every app that already ships this card. The
|
|
102
|
+
* compact density ignores it — a 112px thumbnail has nothing to come forward
|
|
103
|
+
* from.
|
|
104
|
+
*
|
|
105
|
+
* `onLongPress` and `onContextMenu` are the shortcut to a sheet or a menu, and
|
|
106
|
+
* they carry `MessageBubble`'s contract verbatim: a long press on native (and
|
|
107
|
+
* on a web touch screen), right-click on web with the event defaulted. Neither
|
|
108
|
+
* has a keyboard spelling, so whatever they reach must be reachable another
|
|
109
|
+
* way.
|
|
94
110
|
*/
|
|
95
111
|
|
|
96
112
|
const TEXT_GAP = 12;
|
|
@@ -198,6 +214,7 @@ function PhotoTrack({
|
|
|
198
214
|
source={{ uri }}
|
|
199
215
|
resizeMode="cover"
|
|
200
216
|
accessibilityIgnoresInvertColors
|
|
217
|
+
{...webDataSet({ bloomListingCardPhoto: '' })}
|
|
201
218
|
style={{ width: '100%', height: '100%' }}
|
|
202
219
|
/>
|
|
203
220
|
) : null}
|
|
@@ -623,6 +640,9 @@ function ListingCardComponent(props: ListingCardProps) {
|
|
|
623
640
|
favorite = false,
|
|
624
641
|
onFavoriteChange,
|
|
625
642
|
onPress,
|
|
643
|
+
onLongPress,
|
|
644
|
+
onContextMenu,
|
|
645
|
+
hoverZoom = false,
|
|
626
646
|
href,
|
|
627
647
|
loading = false,
|
|
628
648
|
layout = 'vertical',
|
|
@@ -727,6 +747,17 @@ function ListingCardComponent(props: ListingCardProps) {
|
|
|
727
747
|
role: href ? ('link' as const) : onPress ? ('button' as const) : undefined,
|
|
728
748
|
accessibilityLabel: name,
|
|
729
749
|
onPress: onPress || (!IS_WEB && href) ? handlePress : undefined,
|
|
750
|
+
onLongPress,
|
|
751
|
+
// Same shape as `MessageBubble`'s: the event is defaulted here, so the
|
|
752
|
+
// browser's own menu does not open over the card's shortcut.
|
|
753
|
+
...(IS_WEB && onContextMenu !== undefined
|
|
754
|
+
? {
|
|
755
|
+
onContextMenu: (event: { preventDefault?: () => void }) => {
|
|
756
|
+
event.preventDefault?.();
|
|
757
|
+
onContextMenu();
|
|
758
|
+
},
|
|
759
|
+
}
|
|
760
|
+
: null),
|
|
730
761
|
testID: testID ? `${testID}-link` : undefined,
|
|
731
762
|
};
|
|
732
763
|
|
|
@@ -752,7 +783,9 @@ function ListingCardComponent(props: ListingCardProps) {
|
|
|
752
783
|
|
|
753
784
|
return (
|
|
754
785
|
<View
|
|
755
|
-
{...webDataSet(
|
|
786
|
+
{...webDataSet(
|
|
787
|
+
hoverZoom ? { bloomListingCard: layout, bloomListingCardZoom: '' } : { bloomListingCard: layout },
|
|
788
|
+
)}
|
|
756
789
|
style={[{ position: 'relative' }, style]}
|
|
757
790
|
testID={testID}
|
|
758
791
|
>
|
|
@@ -22,15 +22,22 @@ import type { WishlistCardProps } from './types';
|
|
|
22
22
|
* A saved collection of stays.
|
|
23
23
|
*
|
|
24
24
|
* cover a square, radius 16, 2px seams in the page colour:
|
|
25
|
+
* 0 photos the placeholder square, holding `empty` centred
|
|
25
26
|
* 1 photo fills it
|
|
26
27
|
* 2 side by side
|
|
27
28
|
* 3 one tall photo left, two stacked right
|
|
28
29
|
* 4+ a 2×2 (the first four)
|
|
29
|
-
* text 12 below: the name body-semibold,
|
|
30
|
-
* text-secondary
|
|
30
|
+
* text 12 below: `icon` at 16 (in `color`), the name body-semibold, then
|
|
31
|
+
* the description body-regular text-secondary
|
|
31
32
|
*
|
|
32
33
|
* A link on web with `href` (a real `<a>`), otherwise a button with `onPress`;
|
|
33
34
|
* its name is "name, description".
|
|
35
|
+
*
|
|
36
|
+
* `icon` is DECORATIVE and sits beside the name rather than inside it — a glyph
|
|
37
|
+
* put in the `name` string is read out as its character, and cannot be coloured
|
|
38
|
+
* or sized. `color` paints that glyph and tints the empty cover; it is the
|
|
39
|
+
* caller's colour, not a token, because a collection's colour is the person's
|
|
40
|
+
* choice and Bloom has no ramp for it.
|
|
34
41
|
*/
|
|
35
42
|
|
|
36
43
|
const SEAM = 2;
|
|
@@ -57,6 +64,9 @@ function WishlistCardComponent({
|
|
|
57
64
|
photoVariant,
|
|
58
65
|
onPress,
|
|
59
66
|
href,
|
|
67
|
+
icon: Icon,
|
|
68
|
+
color,
|
|
69
|
+
empty,
|
|
60
70
|
accessibilityLabel,
|
|
61
71
|
style,
|
|
62
72
|
testID,
|
|
@@ -67,10 +77,25 @@ function WishlistCardComponent({
|
|
|
67
77
|
const resolver = useImageResolver();
|
|
68
78
|
const uris = photos.slice(0, 4).map((photo) => resolvePhoto(photo, resolver, photoVariant));
|
|
69
79
|
const count = Math.max(1, uris.length);
|
|
80
|
+
const isEmpty = uris.length === 0;
|
|
70
81
|
|
|
71
82
|
const fill: ViewStyle = { flex: 1 };
|
|
72
83
|
let cover: React.ReactNode;
|
|
73
|
-
if (
|
|
84
|
+
if (isEmpty) {
|
|
85
|
+
cover = (
|
|
86
|
+
<View
|
|
87
|
+
style={{
|
|
88
|
+
flex: 1,
|
|
89
|
+
alignItems: 'center',
|
|
90
|
+
justifyContent: 'center',
|
|
91
|
+
backgroundColor: color ?? paint.photoPlaceholder,
|
|
92
|
+
}}
|
|
93
|
+
testID={testID ? `${testID}-empty` : undefined}
|
|
94
|
+
>
|
|
95
|
+
{empty ?? null}
|
|
96
|
+
</View>
|
|
97
|
+
);
|
|
98
|
+
} else if (count === 1) {
|
|
74
99
|
cover = <Tile uri={uris[0]} paint={paint} style={fill} />;
|
|
75
100
|
} else if (count === 2) {
|
|
76
101
|
cover = (
|
|
@@ -133,9 +158,20 @@ function WishlistCardComponent({
|
|
|
133
158
|
{cover}
|
|
134
159
|
</View>
|
|
135
160
|
<View style={{ marginTop: 12, gap: 2 }}>
|
|
136
|
-
<
|
|
137
|
-
{
|
|
138
|
-
|
|
161
|
+
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
|
|
162
|
+
{Icon ? (
|
|
163
|
+
<View aria-hidden style={{ flexShrink: 0 }} testID={testID ? `${testID}-icon` : undefined}>
|
|
164
|
+
<Icon width={16} height={16} fill={color ?? paint.textSecondary} />
|
|
165
|
+
</View>
|
|
166
|
+
) : null}
|
|
167
|
+
<Text
|
|
168
|
+
variant="body-semibold"
|
|
169
|
+
numberOfLines={1}
|
|
170
|
+
style={{ flexShrink: 1, minWidth: 0, color: paint.text }}
|
|
171
|
+
>
|
|
172
|
+
{name}
|
|
173
|
+
</Text>
|
|
174
|
+
</View>
|
|
139
175
|
{description ? (
|
|
140
176
|
<Text variant="body-regular" numberOfLines={1} style={{ color: paint.textSecondary }}>
|
|
141
177
|
{description}
|
|
@@ -20,6 +20,13 @@ export const MAX_DOTS = 5;
|
|
|
20
20
|
export const DOT_SIZE = 6;
|
|
21
21
|
/** A dot at a window edge that has more photos beyond it. */
|
|
22
22
|
export const DOT_EDGE_SIZE = 4;
|
|
23
|
+
/**
|
|
24
|
+
* How far `hoverZoom` scales the photo under a pointer. 1.06 — enough to read
|
|
25
|
+
* as the photo coming forward, small enough that the crop barely moves and a
|
|
26
|
+
* face near an edge is not pushed out of frame. The tile clips, so nothing
|
|
27
|
+
* overflows the card.
|
|
28
|
+
*/
|
|
29
|
+
export const PHOTO_ZOOM_SCALE = 1.06;
|
|
23
30
|
|
|
24
31
|
export interface ListingCardPaint {
|
|
25
32
|
/** The placeholder behind a photo that has not loaded, and the mosaic gaps' tiles. */
|
|
@@ -209,6 +216,7 @@ export const LISTING_CARD_STYLE_ID = 'bloom-listing-card-web-css';
|
|
|
209
216
|
const CARD = '[data-bloom-listing-card]';
|
|
210
217
|
const LINK = '[data-bloom-listing-card-link]';
|
|
211
218
|
const TRACK = '[data-bloom-listing-card-track]';
|
|
219
|
+
const PHOTO = '[data-bloom-listing-card-photo]';
|
|
212
220
|
const ARROW = '[data-bloom-listing-card-arrow]';
|
|
213
221
|
const DOT = '[data-bloom-listing-card-dot]';
|
|
214
222
|
const FAVORITE = '[data-bloom-favorite-button]';
|
|
@@ -262,9 +270,23 @@ ${FAVORITE}:focus-visible {
|
|
|
262
270
|
outline: 2px solid var(--bloom-listing-card-ring, currentColor);
|
|
263
271
|
outline-offset: 0;
|
|
264
272
|
}
|
|
273
|
+
${CARD}[data-bloom-listing-card-zoom] ${PHOTO} {
|
|
274
|
+
transition: transform 400ms cubic-bezier(0.2, 0, 0.2, 1);
|
|
275
|
+
transform-origin: center;
|
|
276
|
+
}
|
|
277
|
+
@media (any-hover: hover) {
|
|
278
|
+
${CARD}[data-bloom-listing-card-zoom]:hover ${PHOTO} {
|
|
279
|
+
transform: scale(${PHOTO_ZOOM_SCALE});
|
|
280
|
+
}
|
|
281
|
+
}
|
|
265
282
|
@media (prefers-reduced-motion: reduce) {
|
|
266
283
|
${ARROW}, ${DOT} {
|
|
267
284
|
transition: none;
|
|
268
285
|
}
|
|
286
|
+
${CARD}[data-bloom-listing-card-zoom] ${PHOTO},
|
|
287
|
+
${CARD}[data-bloom-listing-card-zoom]:hover ${PHOTO} {
|
|
288
|
+
transition: none;
|
|
289
|
+
transform: none;
|
|
290
|
+
}
|
|
269
291
|
}
|
|
270
292
|
`;
|