Skip to content

Commit

Permalink
Styling support
Browse files Browse the repository at this point in the history
  • Loading branch information
barvian committed Nov 18, 2024
1 parent 79ec0f5 commit 27156cc
Show file tree
Hide file tree
Showing 39 changed files with 666 additions and 157 deletions.
8 changes: 8 additions & 0 deletions .changeset/bright-peas-pull.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'number-flow': patch
'@number-flow/svelte': patch
'@number-flow/react': patch
'@number-flow/vue': patch
---

Expose parts for styling support
4 changes: 4 additions & 0 deletions packages/number-flow/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,13 @@
"magic-string": "^0.30.11",
"parse-literals": "^1.2.1",
"playwright": "^1.48.0",
"rollup-plugin-minify-html-literals-v3": "^1.3.4",
"tslib": "^2.7.0",
"typescript": "^5.6.2",
"vite": "^5.4.3",
"vitest": "^2.1.2"
},
"dependencies": {
"esm-env": "^1.1.4"
}
}
33 changes: 17 additions & 16 deletions packages/number-flow/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import { max } from './util/math'
export { define } from './util/dom'

export { prefersReducedMotion } from './styles'
export { render, type RenderProps } from './ssr'
export { renderInnerHTML } from './ssr'
export * from './formatter'

export const canAnimate = supportsMod && supportsLinear && supportsAtProperty
Expand Down Expand Up @@ -125,6 +125,7 @@ export class NumberFlowLite extends ServerSafeHTMLElement implements Props {
if (!this.#created) {
this.#data = data

// This will overwrite the DSD if any:
this.attachShadow({ mode: 'open' })

// Add stylesheet
Expand All @@ -140,25 +141,18 @@ export class NumberFlowLite extends ServerSafeHTMLElement implements Props {
this.shadowRoot!.appendChild(style)
}

this.shadowRoot!.appendChild(createElement('slot'))

this.#pre = new SymbolSection(this, pre, {
inert: true,
ariaHidden: 'true',
justify: 'right'
justify: 'right',
part: 'left'
})
this.shadowRoot!.appendChild(this.#pre.el)

this.#num = new Num(this, integer, fraction, {
inert: true,
ariaHidden: 'true'
})
this.#num = new Num(this, integer, fraction)
this.shadowRoot!.appendChild(this.#num.el)

this.#post = new SymbolSection(this, post, {
inert: true,
ariaHidden: 'true',
justify: 'left'
justify: 'left',
part: 'right'
})
this.shadowRoot!.appendChild(this.#post.el)
} else {
Expand Down Expand Up @@ -260,10 +254,12 @@ class Num {
{ className, ...props }: HTMLProps<'span'> = {}
) {
this.#integer = new NumberSection(flow, integer, {
justify: 'right'
justify: 'right',
part: 'integer'
})
this.#fraction = new NumberSection(flow, fraction, {
justify: 'left'
justify: 'left',
part: 'fraction'
})

this.#inner = createElement(
Expand All @@ -277,6 +273,7 @@ class Num {
'span',
{
...props,
part: 'number',
className: `number ${className ?? ''}`
},
[this.#inner]
Expand Down Expand Up @@ -384,12 +381,14 @@ abstract class Section {

protected unpop(char: Char) {
char.el.classList.remove('section__exiting')
char.el.style.top = ''
char.el.style[this.justify] = ''
}

protected pop(chars: Map<any, Char>) {
// Calculate offsets for removed before popping, to avoid layout thrashing:
chars.forEach((char) => {
char.el.style.top = `${char.el.offsetTop}px`
char.el.style[this.justify] = `${offset(char.el, this.justify)}px`
})
chars.forEach((char) => {
Expand Down Expand Up @@ -609,7 +608,7 @@ class Digit extends Char<KeyedDigitPart> {

constructor(
section: Section,
_: KeyedDigitPart['type'],
type: KeyedDigitPart['type'],
value: KeyedDigitPart['value'],
readonly place: number,
props?: CharProps
Expand All @@ -626,6 +625,7 @@ class Digit extends Char<KeyedDigitPart> {
const el = createElement(
'span',
{
part: `digit ${type}-digit`,
className: `digit`
},
numbers
Expand Down Expand Up @@ -736,6 +736,7 @@ class Sym extends Char<KeyedSymbolPart> {
createElement(
'span',
{
part: type,
className: `symbol`
},
[val]
Expand Down
55 changes: 47 additions & 8 deletions packages/number-flow/src/ssr.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,53 @@
import type { Data } from './formatter'
import { charHeight, maskHeight, SlottedTag } from './styles'
import { BROWSER } from './util/env'
import type { Data, KeyedNumberPart } from './formatter'
import { css, html } from './util/string'
import { charHeight, halfMaskHeight, maskHeight } from './styles'
import { BROWSER } from 'esm-env'

export const ServerSafeHTMLElement = BROWSER
? HTMLElement
: (class {} as unknown as typeof HTMLElement) // for types

export type RenderProps = Pick<Data, 'valueAsString'> & { willChange?: boolean }
const styles = css`
:host {
display: inline-block;
direction: ltr;
white-space: nowrap;
line-height: ${charHeight} !important;
}
// Could eventually use DSD e.g.
// `<template shadowroot="open" shadowrootmode="open">
export const render = ({ valueAsString, willChange }: RenderProps) =>
`<${SlottedTag} style="font-kerning: none; display: inline-block; line-height: ${charHeight}; padding: ${maskHeight} 0;${willChange ? 'will-change: transform' : ''}">${valueAsString}</${SlottedTag}>`
span {
display: inline-block;
}
:host([data-will-change]) span {
will-change: transform;
}
.number,
.digit {
padding: ${halfMaskHeight} 0;
}
.symbol {
white-space: pre; /* some symbols are spaces or thin spaces */
}
`

const renderPart = (part: KeyedNumberPart) =>
`<span class="${part.type === 'integer' || part.type === 'fraction' ? 'digit' : 'symbol'}" part="${part.type === 'integer' || part.type === 'fraction' ? `digit ${part.type}-digit` : part.type}">${part.value}</span>`

const renderSection = (section: KeyedNumberPart[], part: string) =>
`<span part="${part}">${section.reduce((str, p) => str + renderPart(p), '')}</span>`

export const renderInnerHTML = (data: Data) =>
// shadowroot="open" non-standard attribute for old Chrome:
html`<template shadowroot="open" shadowrootmode="open"
><style>
${styles}</style
>${renderSection(data.pre, 'left')}<span part="number" class="number"
>${renderSection(data.integer, 'integer')}${renderSection(data.fraction, 'fraction')}</span
>${renderSection(data.post, 'right')}</template
><span
style="font-kerning: none; display: inline-block; line-height: ${charHeight} !important; padding: ${maskHeight} 0;"
>${data.valueAsString}</span
>` // ^ fallback for browsers that don't support DSD
84 changes: 22 additions & 62 deletions packages/number-flow/src/styles.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { BROWSER } from './util/env'
import { css } from './util/css'
import { BROWSER } from 'esm-env'
import { css } from './util/string'

export const supportsLinear =
BROWSER &&
Expand All @@ -11,7 +11,9 @@ export const supportsMod =
BROWSER && typeof CSS !== 'undefined' && CSS.supports && CSS.supports('line-height', 'mod(1,1)')

export const prefersReducedMotion =
BROWSER && matchMedia ? matchMedia('(prefers-reduced-motion: reduce)') : null
BROWSER && typeof matchMedia !== 'undefined'
? matchMedia('(prefers-reduced-motion: reduce)')
: null

// Register animated vars:
export const opacityDeltaVar = '--_number-flow-d-opacity'
Expand Down Expand Up @@ -60,7 +62,7 @@ export const charHeight = 'var(--number-flow-char-height, 1em)'
// Mask technique taken from:
// https://expensive.toys/blog/blur-vignette
export const maskHeight = 'var(--number-flow-mask-height, 0.25em)'
const halfMaskHeight = `calc(${maskHeight} / 2)`
export const halfMaskHeight = `calc(${maskHeight} / 2)`
const maskWidth = 'var(--number-flow-mask-width, 0.5em)'
const scaledMaskWidth = `calc(${maskWidth} / var(--scale-x))`

Expand All @@ -70,40 +72,20 @@ export const SlottedTag = 'span'

const styles = css`
:host {
display: inline-flex; /* seems better at matching baselines with other inline elements */
align-items: baseline; /* ^ */
display: inline-block;
direction: ltr;
white-space: nowrap;
/* for invisible slotted label used for screen readers and selecting: */
position: relative;
line-height: ${charHeight} !important;
isolation: isolate;
}
::slotted(${SlottedTag}) {
position: absolute;
left: 0;
top: 0;
color: transparent !important;
will-change: unset !important;
z-index: -5;
}
:host > .number,
:host > .section {
pointer-events: none;
user-select: none;
isolation: isolate; /* for .number z-index */
}
.number,
.number__inner {
display: inline-flex;
align-items: baseline;
display: inline-block;
transform-origin: left top;
}
:host([data-will-change]) .number,
:host([data-will-change]) .number__inner {
:host([data-will-change]) :is(.number, .number__inner, .section, .digit, .digit__num, .symbol) {
will-change: transform;
}
Expand All @@ -115,7 +97,7 @@ const styles = css`
position: relative; /* for z-index */
z-index: -1; /* display underneath other sections */
overflow: clip; /* important so it doesn't affect page layout */
/* overflow: clip; /* helpful to not affect page layout, but breaks baseline alignment in Safari :/ */
/* -webkit- prefixed properties have better support than unprefixed ones: */
-webkit-mask-image:
/* Horizontal: */
Expand Down Expand Up @@ -156,35 +138,28 @@ const styles = css`
}
.number__inner {
padding: 0 ${maskWidth};
padding: ${halfMaskHeight} ${maskWidth};
/* invert parent's: */
transform: scaleX(calc(1 / var(--scale-x))) translateX(calc(-1 * var(${dxVar})));
}
.section {
display: inline-flex;
align-items: baseline;
padding-bottom: ${halfMaskHeight};
padding-top: ${halfMaskHeight};
display: inline-block;
/* for .section__exiting: */
position: relative;
isolation: isolate;
}
.section::after {
/*
* We seem to need some type of character to ensure align-items: baseline continues working
* We seem to need some type of character to ensure baseline alignment continues working
* even when empty
*/
content: '\200b'; /* zero-width space */
display: block;
display: inline-block;
padding: ${halfMaskHeight} 0;
}
:host([data-will-change]) .section {
will-change: transform;
}
.section--justify-left {
transform-origin: center left;
}
Expand All @@ -193,25 +168,21 @@ const styles = css`
transform-origin: center right;
}
.section__exiting {
.section__exiting,
.symbol__exiting {
margin: 0 !important;
position: absolute !important;
z-index: -1;
/* top: 0; this seemed to backfire */
}
.digit {
display: block;
display: inline-block;
position: relative;
--c: var(--current) + var(${deltaVar});
}
:host([data-will-change]) .digit,
:host([data-will-change]) .digit__num {
will-change: transform;
}
.digit__num {
display: block;
display: inline-block;
padding: ${halfMaskHeight} 0;
/* Claude + https://buildui.com/recipes/animated-counter */
--offset-raw: mod(10 + var(--n) - mod(var(--c), 10), 10);
Expand All @@ -233,28 +204,17 @@ const styles = css`
}
.symbol {
display: inline-flex;
align-items: baseline;
display: inline-block;
position: relative;
isolation: isolate; /* helpful for z-index and mix-blend-mode */
padding: ${halfMaskHeight} 0;
}
:host([data-will-change]) .symbol {
will-change: transform;
}
.symbol__value {
display: block;
display: inline-block;
mix-blend-mode: plus-lighter; /* better crossfades e.g. + <-> - */
white-space: pre; /* some symbols are spaces or thin spaces */
}
.symbol__exiting {
position: absolute;
z-index: -1;
}
.section--justify-left .symbol__exiting {
left: 0;
}
Expand Down
1 change: 0 additions & 1 deletion packages/number-flow/src/util/css.ts

This file was deleted.

2 changes: 1 addition & 1 deletion packages/number-flow/src/util/dom.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { BROWSER } from './env'
import { BROWSER } from 'esm-env'

type ExcludeReadonly<T> = {
-readonly [K in keyof T as T[K] extends Readonly<any> ? never : K]: T[K]
Expand Down
1 change: 0 additions & 1 deletion packages/number-flow/src/util/env.ts

This file was deleted.

2 changes: 2 additions & 0 deletions packages/number-flow/src/util/string.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const html = String.raw
export const css = String.raw
Loading

0 comments on commit 27156cc

Please sign in to comment.