frappe-ui 1.0.0-beta.63 → 1.0.0-beta.64

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "frappe-ui",
3
- "version": "1.0.0-beta.63",
3
+ "version": "1.0.0-beta.64",
4
4
  "description": "A set of components and utilities for rapid UI development",
5
5
  "engines": {
6
6
  "node": ">=20.19.0"
@@ -9,7 +9,7 @@ import type { AxisChartConfig } from './types'
9
9
 
10
10
  const tokens: ChartTokens = {
11
11
  categorical: ['#111111', '#222222', '#333333'],
12
- sequential: ['#000011', '#000022', '#000033', '#000044', '#000055'],
12
+ sequential: ['#000011', '#000022', '#000033'],
13
13
  diverging: ['#001100', '#002200', '#003300'],
14
14
  axisLabel: 'ink-5',
15
15
  axisTitle: 'ink-7',
@@ -169,9 +169,8 @@ describe('area chart option', () => {
169
169
  build({
170
170
  fillOpacity: 0.5,
171
171
  series: [{ name: 'sales', fillOpacity: 0.25 }],
172
- // A lone series takes the ramp's mid stop, hence the shifted blue.
173
172
  }).series[0].areaStyle.color.colorStops[0].color,
174
- ).toBe('rgba(0, 0, 34, 0.25)')
173
+ ).toBe('rgba(0, 0, 17, 0.25)')
175
174
  })
176
175
 
177
176
  it('falls back to a flat fill for a color it cannot add alpha to', () => {
@@ -7,8 +7,8 @@ import type { AxisChartConfig, AxisChartSeriesConfig } from './types'
7
7
 
8
8
  const tokens: ChartTokens = {
9
9
  categorical: ['#111111', '#222222', '#333333'],
10
- // Five stops so the sequential pale-tail trim and even spacing are visible.
11
- sequential: ['#000011', '#000022', '#000033', '#000044', '#000055'],
10
+ // Three stops so even spacing across the ramp is visible.
11
+ sequential: ['#000011', '#000022', '#000033'],
12
12
  diverging: ['#001100', '#002200', '#003300'],
13
13
  axisLabel: 'ink-5',
14
14
  axisTitle: 'ink-7',
@@ -70,13 +70,13 @@ describe('resolveSeriesColors', () => {
70
70
  ).toEqual({ sales: '#000011', refunds: 'red' })
71
71
  })
72
72
 
73
- it('gives a lone sequential series a mid stop, not the darkest', () => {
73
+ it('gives a lone sequential series the darkest stop', () => {
74
74
  expect(colorsFor({ series: [{ name: 'sales' }] })).toEqual({
75
- sales: '#000022',
75
+ sales: '#000011',
76
76
  })
77
77
  })
78
78
 
79
- it('spaces sequential series out dark to light, skipping the palest stops', () => {
79
+ it('spaces sequential series out dark to light', () => {
80
80
  expect(Object.values(colorsFor({ series: named(3) }))).toEqual([
81
81
  '#000011',
82
82
  '#000022',
@@ -84,10 +84,10 @@ describe('resolveSeriesColors', () => {
84
84
  ])
85
85
  })
86
86
 
87
- it('cycles the sequential ramp once there are more series than usable stops', () => {
88
- const colors = colorsFor({ series: named(6) })
87
+ it('cycles the sequential ramp once there are more series than stops', () => {
88
+ const colors = colorsFor({ series: named(4) })
89
89
  expect(colors.a).toBe('#000011')
90
- expect(colors.f).toBe(colors.a)
90
+ expect(colors.d).toBe(colors.a)
91
91
  })
92
92
 
93
93
  it('cycles the categorical ramp in series order', () => {
@@ -25,9 +25,6 @@ const tokens: ChartTokens = {
25
25
  backdrop: '#ffffff',
26
26
  }
27
27
 
28
- /** The sequential ramp minus the two palest stops, as every chart reads it. */
29
- const USABLE_SEQUENTIAL = tokens.sequential.slice(0, 7)
30
-
31
28
  describe('chartColors: precedence', () => {
32
29
  it("draws in the caller's own colors when they passed a list", () => {
33
30
  expect(
@@ -56,7 +53,7 @@ describe('chartColors: precedence', () => {
56
53
  ).toEqual(tokens.categorical.slice(0, 2))
57
54
  expect(
58
55
  chartColors([], tokens, { fallback: 'sequential', count: 'ramp' }),
59
- ).toEqual(USABLE_SEQUENTIAL)
56
+ ).toEqual(tokens.sequential)
60
57
  })
61
58
  })
62
59
 
@@ -90,6 +87,38 @@ describe('chartColors: one color per thing drawn', () => {
90
87
  })
91
88
  })
92
89
 
90
+ describe('chartColors: how a sequential ramp is spent', () => {
91
+ const stops = (count: number) =>
92
+ chartColors('sequential', tokens, { fallback: 'sequential', count }).map(
93
+ (color) => tokens.sequential.indexOf(color) + 1,
94
+ )
95
+
96
+ it('gives a lone series the deep end', () => {
97
+ expect(stops(1)).toEqual([1])
98
+ })
99
+
100
+ it('keeps a small chart a prefix of the next size up', () => {
101
+ expect(stops(2)).toEqual([1, 4])
102
+ expect(stops(3)).toEqual([1, 4, 7])
103
+ })
104
+
105
+ it('stays inside the first seven stops up to seven series', () => {
106
+ expect(stops(4)).toEqual([1, 3, 5, 7])
107
+ expect(stops(5)).toEqual([1, 3, 4, 6, 7])
108
+ expect(stops(6)).toEqual([1, 2, 3, 5, 6, 7])
109
+ expect(stops(7)).toEqual([1, 2, 3, 4, 5, 6, 7])
110
+ })
111
+
112
+ it('reaches the pale stops only once the series outnumber the span', () => {
113
+ expect(stops(8)).toEqual([1, 2, 3, 4, 5, 6, 7, 8])
114
+ expect(stops(9)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9])
115
+ })
116
+
117
+ it('cycles once there are more series than stops', () => {
118
+ expect(stops(10)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 1])
119
+ })
120
+ })
121
+
93
122
  describe('chartColors: the ramp itself', () => {
94
123
  it('gives the stops rather than a slot each', () => {
95
124
  expect(
@@ -97,16 +126,7 @@ describe('chartColors: the ramp itself', () => {
97
126
  fallback: 'sequential',
98
127
  count: 'ramp',
99
128
  }),
100
- ).toEqual(USABLE_SEQUENTIAL)
101
- })
102
-
103
- it('trims the sequential stops that vanish against a card', () => {
104
- const ramp = chartColors(undefined, tokens, {
105
- fallback: 'sequential',
106
- count: 'ramp',
107
- })
108
- expect(ramp).not.toContain('#f5f5f5')
109
- expect(ramp).not.toContain('#e0e0e0')
129
+ ).toEqual(tokens.sequential)
110
130
  })
111
131
 
112
132
  it('takes a diverging ramp end to end', () => {
@@ -151,7 +171,7 @@ describe('chartColors: which end of the ramp leads', () => {
151
171
  count: 'ramp',
152
172
  deepEnd: 'last',
153
173
  }),
154
- ).toEqual([...USABLE_SEQUENTIAL].reverse())
174
+ ).toEqual([...tokens.sequential].reverse())
155
175
  })
156
176
 
157
177
  it('leaves a categorical set alone, having no order to reverse', () => {
@@ -9,7 +9,7 @@ import type { AxisChartConfig, ChartMark } from './types'
9
9
 
10
10
  const tokens: ChartTokens = {
11
11
  categorical: ['#111111', '#222222', '#333333'],
12
- sequential: ['#000011', '#000022', '#000033', '#000044', '#000055'],
12
+ sequential: ['#000011', '#000022', '#000033'],
13
13
  diverging: ['#001100', '#002200', '#003300'],
14
14
  axisLabel: 'ink-5',
15
15
  axisTitle: 'ink-7',
@@ -6,7 +6,7 @@ import type { DonutChartConfig } from './types'
6
6
 
7
7
  const tokens: ChartTokens = {
8
8
  categorical: ['#111111', '#222222', '#333333'],
9
- sequential: ['#000011', '#000022', '#000033', '#000044', '#000055'],
9
+ sequential: ['#000011', '#000022', '#000033'],
10
10
  diverging: ['#001100', '#002200', '#003300'],
11
11
  axisLabel: 'ink-5',
12
12
  axisTitle: 'ink-7',
@@ -203,18 +203,10 @@ describe('buildHeatmapMatrix', () => {
203
203
  })
204
204
 
205
205
  describe('heatmapRampStops', () => {
206
- it('reverses the sequential ramp and trims its palest stops', () => {
207
- const stops = heatmapRampStops(config(), tokens)
208
-
209
- expect(stops).toEqual([
210
- '#cccccc',
211
- '#aaaaaa',
212
- '#8a8a8a',
213
- '#5a5a5a',
214
- '#3a3a3a',
215
- '#1a1a1a',
216
- '#0a0a0a',
217
- ])
206
+ it('reverses the sequential ramp, palest first', () => {
207
+ expect(heatmapRampStops(config(), tokens)).toEqual(
208
+ [...tokens.sequential].reverse(),
209
+ )
218
210
  })
219
211
 
220
212
  it('takes the diverging ramp as authored, cool end first', () => {
@@ -347,7 +339,9 @@ describe('buildHeatmapOption', () => {
347
339
  const resting = cells[index].color
348
340
  const hovered = item.emphasis.itemStyle.color
349
341
  expect(hovered).toBe(hoverCellColor(resting))
350
- expect(hexToOklch(hovered).l).toBeGreaterThan(hexToOklch(resting).l)
342
+ expect(hexToOklch(hovered).l).toBeGreaterThanOrEqual(
343
+ hexToOklch(resting).l,
344
+ )
351
345
  })
352
346
  })
353
347
  })
@@ -5,7 +5,7 @@ import type { AxisChartConfig } from './types'
5
5
 
6
6
  const tokens: ChartTokens = {
7
7
  categorical: ['#111111', '#222222', '#333333'],
8
- sequential: ['#000011', '#000022', '#000033', '#000044', '#000055'],
8
+ sequential: ['#000011', '#000022', '#000033'],
9
9
  diverging: ['#001100', '#002200', '#003300'],
10
10
  axisLabel: 'ink-5',
11
11
  axisTitle: 'ink-7',
@@ -25,7 +25,10 @@
25
25
  The red family sits at hue 17. It was authored at 5, a crimson, whose light
26
26
  partner lightens into a pink rather than into a warning — so anything
27
27
  reading the ramp for a status had to leave it. 17 keeps the family clear of
28
- the amber at 75 while its light partner still reads as red. */
28
+ the amber at 75 while its light partner still reads as red. Its chroma
29
+ sits at 0.155, under the cap, because the eye weights red above every
30
+ other hue and at the cap a slice of it reads as an alert. Lower still and
31
+ it turns brown. */
29
32
  --chart-categorical-1: #2283c3;
30
33
  --chart-categorical-2: #84c5f9;
31
34
  --chart-categorical-3: #289e60;
@@ -34,8 +37,8 @@
34
37
  --chart-categorical-6: #bb9df1;
35
38
  --chart-categorical-7: #c98c28;
36
39
  --chart-categorical-8: #f5ca8e;
37
- --chart-categorical-9: #bd2040;
38
- --chart-categorical-10: #fc8e94;
40
+ --chart-categorical-9: #c54b58;
41
+ --chart-categorical-10: #fca0a4;
39
42
 
40
43
  --chart-sequential-1: #095895;
41
44
  --chart-sequential-2: #2283c3;
@@ -95,8 +98,8 @@
95
98
  --chart-categorical-6: #b294e7;
96
99
  --chart-categorical-7: #bf8319;
97
100
  --chart-categorical-8: #ebc085;
98
- --chart-categorical-9: #b20e37;
99
- --chart-categorical-10: #f2858b;
101
+ --chart-categorical-9: #ba4250;
102
+ --chart-categorical-10: #f2979b;
100
103
 
101
104
  --chart-sequential-1: #074677;
102
105
  --chart-sequential-2: #1b699c;
@@ -56,8 +56,8 @@ const LIGHT_CATEGORICAL = [
56
56
  '#bb9df1',
57
57
  '#c98c28',
58
58
  '#f5ca8e',
59
- '#bd2040',
60
- '#fc8e94',
59
+ '#c54b58',
60
+ '#fca0a4',
61
61
  ]
62
62
 
63
63
  const LIGHT_SEQUENTIAL = [
@@ -95,8 +95,8 @@ const DARK_CATEGORICAL = [
95
95
  '#b294e7',
96
96
  '#bf8319',
97
97
  '#ebc085',
98
- '#b20e37',
99
- '#f2858b',
98
+ '#ba4250',
99
+ '#f2979b',
100
100
  ]
101
101
 
102
102
  const DARK_SEQUENTIAL = [
@@ -277,10 +277,6 @@ export function pickSeriesColor(ramp: string[], index: number) {
277
277
  return ramp[index % ramp.length]
278
278
  }
279
279
 
280
- /** The two palest sequential stops vanish against a white card. */
281
- const SEQUENTIAL_TAIL_TRIM = 2
282
- /** A lone series reads best as one confident mid-blue, not the ramp's dark end. */
283
- const SEQUENTIAL_SOLO_INDEX = 1
284
280
  /**
285
281
  * Below this relative luminance a fill needs white text on top of it. It is the
286
282
  * crossover, not a taste call: white and the near-black inside-label ink
@@ -290,23 +286,57 @@ const SEQUENTIAL_SOLO_INDEX = 1
290
286
  const DARK_FILL_LUMINANCE = 0.22
291
287
 
292
288
  /**
293
- * `count` evenly spaced stops from a continuous ramp, so a stack reads as one
294
- * progression rather than n unrelated hues. Cycles instead once there are more
295
- * series than usable stops, where even spacing would hand out duplicates.
289
+ * How much of the sequential ramp a chart spends before it must widen. The
290
+ * last two stops of a nine-stop ramp are pale enough that a series in them is
291
+ * hard to read against a card, so a chart stays inside the first seven until
292
+ * it has more series than that.
296
293
  */
297
- function rampStops(ramp: string[], count: number, lastIndex: number): string[] {
298
- if (count > lastIndex + 1) {
294
+ const SEQUENTIAL_SPAN = 7
295
+ /**
296
+ * The most stops apart two neighbouring series sit. Without it two series
297
+ * take the two ends of the span, and a third then recolors the second. With
298
+ * it a chart of one, two or three series is a prefix of the next.
299
+ */
300
+ const SEQUENTIAL_MAX_STEP = 3
301
+
302
+ /**
303
+ * `count` stops from the deep end of a sequential ramp: spread evenly over the
304
+ * first `SEQUENTIAL_SPAN` stops, never more than `SEQUENTIAL_MAX_STEP` apart,
305
+ * widening past the span only for a chart with more series than it holds.
306
+ * Cycles once there are more series than stops.
307
+ */
308
+ function sequentialStops(ramp: string[], count: number): string[] {
309
+ if (count === 1) return [ramp[0]]
310
+ if (count > ramp.length) {
299
311
  return Array.from({ length: count }, (_, i) => pickSeriesColor(ramp, i))
300
312
  }
313
+ const lastIndex = Math.min(Math.max(SEQUENTIAL_SPAN, count), ramp.length) - 1
301
314
  return Array.from(
302
315
  { length: count },
303
- (_, i) => ramp[Math.round((i * lastIndex) / (count - 1))],
316
+ (_, i) =>
317
+ ramp[
318
+ Math.min(
319
+ Math.round((i * lastIndex) / (count - 1)),
320
+ i * SEQUENTIAL_MAX_STEP,
321
+ )
322
+ ],
304
323
  )
305
324
  }
306
325
 
307
- /** The sequential ramp without the stops that vanish against a card. */
308
- function usableSequential(ramp: string[]) {
309
- return ramp.slice(0, Math.max(1, ramp.length - SEQUENTIAL_TAIL_TRIM))
326
+ /**
327
+ * `count` evenly spaced stops across the whole of a diverging ramp, which is
328
+ * read by its extremes. Cycles once there are more series than stops.
329
+ */
330
+ function divergingStops(ramp: string[], count: number): string[] {
331
+ if (count === 1) return [ramp[0]]
332
+ if (count > ramp.length) {
333
+ return Array.from({ length: count }, (_, i) => pickSeriesColor(ramp, i))
334
+ }
335
+ const lastIndex = ramp.length - 1
336
+ return Array.from(
337
+ { length: count },
338
+ (_, i) => ramp[Math.round((i * lastIndex) / (count - 1))],
339
+ )
310
340
  }
311
341
 
312
342
  /**
@@ -328,16 +358,9 @@ export function paletteColors(
328
358
 
329
359
  const ramp = name === 'diverging' ? tokens.diverging : tokens.sequential
330
360
  if (!ramp.length) return cycle(tokens.categorical)
331
-
332
- if (count === 1) {
333
- if (name === 'diverging') return [ramp[0]]
334
- return [ramp[Math.min(SEQUENTIAL_SOLO_INDEX, ramp.length - 1)]]
335
- }
336
-
337
- // A diverging ramp is read by its extremes, so it always spans end to end.
338
- const lastIndex =
339
- name === 'diverging' ? ramp.length - 1 : usableSequential(ramp).length - 1
340
- return rampStops(ramp, count, lastIndex)
361
+ return name === 'diverging'
362
+ ? divergingStops(ramp, count)
363
+ : sequentialStops(ramp, count)
341
364
  }
342
365
 
343
366
  /**
@@ -347,10 +370,7 @@ export function paletteColors(
347
370
  */
348
371
  function namedRamp(name: ChartPaletteName, tokens: ChartTokens): string[] {
349
372
  if (name === 'categorical') return tokens.categorical
350
- const ramp =
351
- name === 'diverging'
352
- ? tokens.diverging
353
- : usableSequential(tokens.sequential)
373
+ const ramp = name === 'diverging' ? tokens.diverging : tokens.sequential
354
374
  return ramp.length ? ramp : tokens.categorical
355
375
  }
356
376
 
@@ -359,4 +359,40 @@ describe('slot-derived computeds follow the slots', () => {
359
359
  cy.get('[data-cy="toggle"]').click()
360
360
  cy.get('[data-cy="mark"]').should('not.exist')
361
361
  })
362
+
363
+ it('Pill does not reserve space for a conditional empty suffix', () => {
364
+ const Harness = defineComponent({
365
+ setup() {
366
+ const show = ref(false)
367
+ return () =>
368
+ h('div', [
369
+ h(
370
+ 'button',
371
+ {
372
+ 'data-cy': 'toggle',
373
+ onClick: () => (show.value = !show.value),
374
+ },
375
+ 'Toggle',
376
+ ),
377
+ h(
378
+ Pill,
379
+ { label: 'Discussions' },
380
+ {
381
+ suffix: () =>
382
+ show.value ? h('span', { 'data-cy': 'count' }, '2') : null,
383
+ },
384
+ ),
385
+ ])
386
+ },
387
+ })
388
+
389
+ cy.mount(Harness)
390
+ cy.get('[data-slot="tab-suffix"]')
391
+ .should('have.class', 'empty:hidden')
392
+ .and('not.be.visible')
393
+
394
+ cy.get('[data-cy="toggle"]').click()
395
+ cy.get('[data-slot="tab-suffix"]').should('be.visible')
396
+ cy.get('[data-cy="count"]').should('have.text', '2')
397
+ })
362
398
  })
@@ -104,7 +104,6 @@ const iconClass = computed(() => {
104
104
  function hasLabel(label: PillProps['label']) {
105
105
  return label !== undefined && label !== null && label !== ''
106
106
  }
107
-
108
107
  </script>
109
108
 
110
109
  <template>
@@ -115,7 +114,7 @@ function hasLabel(label: PillProps['label']) {
115
114
  <span
116
115
  v-if="$slots.prefix"
117
116
  data-slot="tab-prefix"
118
- class="inline-flex items-center"
117
+ class="inline-flex items-center empty:hidden"
119
118
  >
120
119
  <slot name="prefix" />
121
120
  </span>
@@ -131,7 +130,7 @@ function hasLabel(label: PillProps['label']) {
131
130
  <span
132
131
  v-if="$slots.suffix"
133
132
  data-slot="tab-suffix"
134
- class="inline-flex items-center"
133
+ class="inline-flex items-center empty:hidden"
135
134
  >
136
135
  <slot name="suffix" />
137
136
  </span>
@@ -158,6 +158,36 @@ describe('media node view in read mode', () => {
158
158
  })
159
159
  })
160
160
 
161
+ describe('media node view native video fullscreen', () => {
162
+ it('hides editing chrome without restyling the inline container', async () => {
163
+ const ctx = mount(
164
+ '<p><video src="/files/clip.mp4" data-caption="Release demo"></video></p>',
165
+ )
166
+ await settle()
167
+
168
+ const video = ctx.root.querySelector('video') as HTMLVideoElement
169
+ const container = video.closest(
170
+ '[data-video-fullscreen-root]',
171
+ ) as HTMLElement
172
+ expect(captionInputs(ctx.root)).toHaveLength(1)
173
+
174
+ video.dispatchEvent(new Event('webkitbeginfullscreen'))
175
+ await settle()
176
+
177
+ expect(captionInputs(ctx.root)).toHaveLength(0)
178
+ expect(container.classList).not.toContain('bg-black')
179
+ expect(video.classList).toContain('rounded-4')
180
+ expect(video.classList).not.toContain('rounded-none')
181
+ expect(video.classList).not.toContain('size-full')
182
+
183
+ video.dispatchEvent(new Event('webkitendfullscreen'))
184
+ await settle()
185
+ expect(captionInputs(ctx.root)).toHaveLength(1)
186
+
187
+ ctx.app.unmount()
188
+ })
189
+ })
190
+
161
191
  describe('media node view reuse', () => {
162
192
  it('does not carry a caption over to the next image', async () => {
163
193
  const ctx = mount(
@@ -54,22 +54,25 @@ const isEditable = useNodeViewEditable(editor)
54
54
  const isVideo = computed(() => props.node.type.name === 'video')
55
55
 
56
56
  /**
57
- * Whether THIS media is the fullscreen element.
57
+ * Whether THIS media is fullscreen through either browser API.
58
58
  *
59
59
  * The browser stretches the fullscreen element to the viewport with UA
60
60
  * `!important` rules, but everything inside it kept the committed pixel size:
61
61
  * the video stayed small at the top, its playback bar pinned under it, with a
62
62
  * black slab filling the rest of the screen. In fullscreen the media is
63
63
  * centered in the black field and the controls run along the bottom of the
64
- * screen — and the editing chrome (toolbar, resize handles, caption) is not
65
- * rendered at all, since none of it is actionable there.
64
+ * screen. iPhone instead moves the video into its native player and leaves the
65
+ * in-page container alone. Editing chrome (toolbar, resize handles, caption)
66
+ * is not rendered in either mode, since none of it is actionable there.
66
67
  */
67
68
  const isFullscreen = ref(false)
69
+ const isStandardFullscreen = ref(false)
68
70
 
69
71
  function syncFullscreen(): void {
70
- isFullscreen.value =
72
+ isStandardFullscreen.value =
71
73
  containerRef.value !== null &&
72
74
  document.fullscreenElement === containerRef.value
75
+ isFullscreen.value = isStandardFullscreen.value
73
76
  }
74
77
 
75
78
  onMounted(() => document.addEventListener('fullscreenchange', syncFullscreen))
@@ -311,7 +314,7 @@ function setVideoOptions(options: {
311
314
  ref="containerRef"
312
315
  class="group relative isolate overflow-hidden not-prose rounded-4"
313
316
  :class="
314
- isFullscreen
317
+ isStandardFullscreen
315
318
  ? 'flex items-center justify-center bg-black'
316
319
  : containerClasses(node.attrs, selected)
317
320
  "
@@ -322,7 +325,8 @@ function setVideoOptions(options: {
322
325
  v-if="isUploaded || fileContent || node.attrs.loading"
323
326
  class="relative"
324
327
  :class="
325
- isFullscreen && 'flex h-full w-full items-center justify-center'
328
+ isStandardFullscreen &&
329
+ 'flex h-full w-full items-center justify-center'
326
330
  "
327
331
  >
328
332
  <img
@@ -356,7 +360,7 @@ function setVideoOptions(options: {
356
360
  // committed size the width/height attributes carry. `!outline-none`
357
361
  // drops the selected-node outline from `style.css`, which would
358
362
  // otherwise frame the video mid-screen.
359
- isFullscreen &&
363
+ isStandardFullscreen &&
360
364
  'size-full rounded-none object-contain !outline-none',
361
365
  ]"
362
366
  :src="node.attrs.src || fileContent"
@@ -371,9 +375,10 @@ function setVideoOptions(options: {
371
375
 
372
376
  <VideoControls
373
377
  v-if="isVideo && isUploaded"
378
+ v-model:fullscreen="isFullscreen"
374
379
  :video-el="mediaRef as HTMLVideoElement | null"
375
380
  :hidden="isResizing"
376
- :fullscreen="isFullscreen"
381
+ :standard-fullscreen="isStandardFullscreen"
377
382
  />
378
383
 
379
384
  <MediaToolbar
@@ -30,6 +30,7 @@ beforeEach(() => {
30
30
 
31
31
  afterEach(() => {
32
32
  vi.unstubAllGlobals()
33
+ Reflect.deleteProperty(document, 'fullscreenEnabled')
33
34
  })
34
35
 
35
36
  /** Run one animation frame, the way the browser would. */
@@ -39,15 +40,33 @@ async function paint() {
39
40
  await nextTick()
40
41
  }
41
42
 
42
- function mount(videoEl: HTMLVideoElement) {
43
+ function mount(
44
+ videoEl: HTMLVideoElement,
45
+ options: {
46
+ fullscreen?: boolean
47
+ standardFullscreen?: boolean
48
+ onUpdateFullscreen?: (fullscreen: boolean) => void
49
+ } = {},
50
+ ) {
43
51
  const root = document.createElement('div')
44
52
  document.body.appendChild(root)
45
- const app = createApp({ render: () => h(VideoControls, { videoEl }) })
53
+ const app = createApp({
54
+ render: () =>
55
+ h(VideoControls, {
56
+ videoEl,
57
+ fullscreen: options.fullscreen,
58
+ standardFullscreen: options.standardFullscreen,
59
+ 'onUpdate:fullscreen': options.onUpdateFullscreen,
60
+ }),
61
+ })
46
62
  app.mount(root)
47
63
  return {
48
64
  root,
49
65
  fill: () => root.querySelector<HTMLElement>('[aria-label="Seek"] div div'),
50
66
  slider: () => root.querySelector<HTMLElement>('[aria-label="Seek"]'),
67
+ controls: () => root.firstElementChild as HTMLElement,
68
+ fullscreenButton: () =>
69
+ root.querySelector<HTMLButtonElement>('[aria-label="Fullscreen"]'),
51
70
  unmount: () => {
52
71
  app.unmount()
53
72
  root.remove()
@@ -117,3 +136,100 @@ describe('VideoControls playhead', () => {
117
136
  ctx.unmount()
118
137
  })
119
138
  })
139
+
140
+ describe('VideoControls fullscreen', () => {
141
+ it('fullscreens the media wrapper with the standard API', () => {
142
+ const video = fakeVideo()
143
+ const wrapper = document.createElement('div')
144
+ wrapper.dataset.videoFullscreenRoot = ''
145
+ wrapper.appendChild(video)
146
+ const requestFullscreen = vi.fn(() => Promise.resolve())
147
+ Object.defineProperty(wrapper, 'requestFullscreen', {
148
+ value: requestFullscreen,
149
+ })
150
+ const ctx = mount(video)
151
+
152
+ ctx.fullscreenButton()?.click()
153
+
154
+ expect(requestFullscreen).toHaveBeenCalledOnce()
155
+ ctx.unmount()
156
+ wrapper.remove()
157
+ })
158
+
159
+ it('uses native video fullscreen when the standard API is disabled', () => {
160
+ const video = fakeVideo() as HTMLVideoElement & {
161
+ webkitSupportsFullscreen: boolean
162
+ webkitEnterFullscreen: () => void
163
+ }
164
+ const wrapper = document.createElement('div')
165
+ wrapper.dataset.videoFullscreenRoot = ''
166
+ wrapper.appendChild(video)
167
+ const requestFullscreen = vi.fn(() => Promise.resolve())
168
+ Object.defineProperty(wrapper, 'requestFullscreen', {
169
+ value: requestFullscreen,
170
+ })
171
+ Object.defineProperty(document, 'fullscreenEnabled', {
172
+ configurable: true,
173
+ value: false,
174
+ })
175
+ video.webkitSupportsFullscreen = true
176
+ video.webkitEnterFullscreen = vi.fn()
177
+ const ctx = mount(video)
178
+
179
+ ctx.fullscreenButton()?.click()
180
+
181
+ expect(requestFullscreen).not.toHaveBeenCalled()
182
+ expect(video.webkitEnterFullscreen).toHaveBeenCalledOnce()
183
+ ctx.unmount()
184
+ wrapper.remove()
185
+ })
186
+
187
+ it('handles a rejected standard fullscreen request', async () => {
188
+ const video = fakeVideo()
189
+ const requestFullscreen = vi.fn(() =>
190
+ Promise.reject(new DOMException('Fullscreen denied', 'NotAllowedError')),
191
+ )
192
+ Object.defineProperty(video, 'requestFullscreen', {
193
+ value: requestFullscreen,
194
+ })
195
+ const ctx = mount(video)
196
+
197
+ ctx.fullscreenButton()?.click()
198
+ await Promise.resolve()
199
+
200
+ expect(requestFullscreen).toHaveBeenCalledOnce()
201
+ ctx.unmount()
202
+ })
203
+
204
+ it('reports native WebKit fullscreen transitions', () => {
205
+ const video = fakeVideo()
206
+ const onUpdateFullscreen = vi.fn()
207
+ const ctx = mount(video, { onUpdateFullscreen })
208
+
209
+ video.dispatchEvent(new Event('webkitbeginfullscreen'))
210
+ video.dispatchEvent(new Event('webkitendfullscreen'))
211
+
212
+ expect(onUpdateFullscreen).toHaveBeenNthCalledWith(1, true)
213
+ expect(onUpdateFullscreen).toHaveBeenNthCalledWith(2, false)
214
+ ctx.unmount()
215
+ })
216
+
217
+ it('keeps inline spacing for native fullscreen', () => {
218
+ const native = mount(fakeVideo(), { fullscreen: true })
219
+
220
+ expect(native.controls().classList).toContain('rounded-b-4')
221
+ expect(native.controls().classList).not.toContain('px-6')
222
+ expect(
223
+ native.root.querySelector('[aria-label="Exit fullscreen"]'),
224
+ ).not.toBeNull()
225
+ native.unmount()
226
+
227
+ const standard = mount(fakeVideo(), {
228
+ fullscreen: true,
229
+ standardFullscreen: true,
230
+ })
231
+ expect(standard.controls().classList).toContain('px-6')
232
+ expect(standard.controls().classList).not.toContain('rounded-b-4')
233
+ standard.unmount()
234
+ })
235
+ })
@@ -35,13 +35,24 @@ const props = defineProps<{
35
35
  videoEl: HTMLVideoElement | null
36
36
  /** Hidden entirely while true (e.g. during a resize drag). */
37
37
  hidden?: boolean
38
- /**
39
- * The media is the fullscreen element: the row spans the screen instead of a
40
- * media box, so it drops the rounded corner and takes more breathing room.
41
- */
42
- fullscreen?: boolean
38
+ /** The wrapper uses the standard API and stretches this row to the screen. */
39
+ standardFullscreen?: boolean
43
40
  }>()
44
41
 
42
+ /**
43
+ * Whether this video is fullscreen through either browser API. The node view
44
+ * writes standard Fullscreen API changes; WebKit native-player events write
45
+ * their changes here.
46
+ */
47
+ const fullscreen = defineModel<boolean>('fullscreen', { default: false })
48
+
49
+ type WebKitVideoElement = HTMLVideoElement & {
50
+ readonly webkitSupportsFullscreen?: boolean
51
+ readonly webkitDisplayingFullscreen?: boolean
52
+ webkitEnterFullscreen?: () => void
53
+ webkitExitFullscreen?: () => void
54
+ }
55
+
45
56
  const playing = ref(false)
46
57
  const muted = ref(false)
47
58
  const currentTime = ref(0)
@@ -97,6 +108,16 @@ function bind(el: HTMLVideoElement) {
97
108
  on('loadedmetadata', () => (duration.value = el.duration || 0))
98
109
  on('durationchange', () => (duration.value = el.duration || 0))
99
110
  on('volumechange', () => (muted.value = el.muted))
111
+ // iPhone presents video through its native fullscreen player rather than
112
+ // the standard Fullscreen API, so `document.fullscreenElement` never moves.
113
+ // Write those transitions through the shared fullscreen model to keep the
114
+ // node view's editing chrome in sync with either implementation.
115
+ on('webkitbeginfullscreen', () => (fullscreen.value = true))
116
+ on('webkitendfullscreen', () => (fullscreen.value = false))
117
+
118
+ if ((el as WebKitVideoElement).webkitDisplayingFullscreen) {
119
+ fullscreen.value = true
120
+ }
100
121
  }
101
122
 
102
123
  function unbind(el: HTMLVideoElement) {
@@ -158,16 +179,67 @@ function toggleMute() {
158
179
  if (el) el.muted = !el.muted
159
180
  }
160
181
 
182
+ function ignoreFullscreenRejection(promise: Promise<void> | undefined) {
183
+ // A fullscreen request can be refused by browser policy. It is an optional
184
+ // presentation affordance, so a rejection must not become an unhandled app
185
+ // error.
186
+ void promise?.catch(() => {})
187
+ }
188
+
161
189
  function toggleFullscreen() {
162
190
  const el = props.videoEl
163
191
  if (!el) return
192
+ const webkitVideo = el as WebKitVideoElement
193
+
194
+ if (webkitVideo.webkitDisplayingFullscreen) {
195
+ try {
196
+ webkitVideo.webkitExitFullscreen?.()
197
+ } catch {
198
+ // The native player may already be leaving fullscreen.
199
+ }
200
+ return
201
+ }
202
+
164
203
  if (document.fullscreenElement) {
165
- void document.exitFullscreen()
204
+ try {
205
+ ignoreFullscreenRejection(document.exitFullscreen?.())
206
+ } catch {
207
+ // Fullscreen state may have changed between the check and the request.
208
+ }
166
209
  return
167
210
  }
211
+
168
212
  // Fullscreen the wrapper so these controls stay visible over the video.
169
213
  const root = el.closest('[data-video-fullscreen-root]') ?? el
170
- void (root as HTMLElement).requestFullscreen?.()
214
+ const requestFullscreen = (root as HTMLElement).requestFullscreen
215
+
216
+ // `fullscreenEnabled` is false on iPhone even though the video-specific
217
+ // WebKit API is available. Choose that path before making an asynchronous
218
+ // standard request: `webkitEnterFullscreen()` must run directly inside the
219
+ // click's user-activation window.
220
+ if (
221
+ typeof requestFullscreen === 'function' &&
222
+ document.fullscreenEnabled !== false
223
+ ) {
224
+ try {
225
+ ignoreFullscreenRejection(requestFullscreen.call(root))
226
+ return
227
+ } catch {
228
+ // A synchronous standard-API failure can still fall through to WebKit
229
+ // while this click retains user activation.
230
+ }
231
+ }
232
+
233
+ if (
234
+ webkitVideo.webkitSupportsFullscreen &&
235
+ typeof webkitVideo.webkitEnterFullscreen === 'function'
236
+ ) {
237
+ try {
238
+ webkitVideo.webkitEnterFullscreen()
239
+ } catch {
240
+ // Native fullscreen can be unavailable until media metadata is ready.
241
+ }
242
+ }
171
243
  }
172
244
 
173
245
  function timeFromPointer(event: PointerEvent): number {
@@ -267,7 +339,7 @@ const SCRIM =
267
339
  class="absolute inset-x-0 bottom-0 z-20 flex items-center gap-3 text-white transition-opacity [filter:drop-shadow(0_1px_2px_rgb(0_0_0/0.45))]"
268
340
  :style="{ backgroundImage: SCRIM }"
269
341
  :class="[
270
- fullscreen ? 'px-6 pt-16 pb-6' : 'rounded-b-4 px-3.5 pt-12 pb-3',
342
+ standardFullscreen ? 'px-6 pt-16 pb-6' : 'rounded-b-4 px-3.5 pt-12 pb-3',
271
343
  playing && !scrubbing
272
344
  ? 'opacity-0 group-hover:opacity-100 focus-within:opacity-100'
273
345
  : 'opacity-100',
@@ -367,14 +439,20 @@ const SCRIM =
367
439
  </button>
368
440
  </Tooltip>
369
441
 
370
- <Tooltip text="Fullscreen" class="flex h-5">
442
+ <Tooltip
443
+ :text="fullscreen ? 'Exit fullscreen' : 'Fullscreen'"
444
+ class="flex h-5"
445
+ >
371
446
  <button
372
447
  type="button"
373
448
  class="opacity-90 transition-opacity hover:opacity-100"
374
- aria-label="Fullscreen"
449
+ :aria-label="fullscreen ? 'Exit fullscreen' : 'Fullscreen'"
375
450
  @click="toggleFullscreen"
376
451
  >
377
- <span class="lucide-maximize-2 size-5" />
452
+ <span
453
+ :class="fullscreen ? 'lucide-minimize-2' : 'lucide-maximize-2'"
454
+ class="size-5"
455
+ />
378
456
  </button>
379
457
  </Tooltip>
380
458
  </div>