diff options
Diffstat (limited to 'preact/demo/people')
-rw-r--r-- | preact/demo/people/Readme.md | 3 | ||||
-rw-r--r-- | preact/demo/people/index.tsx | 59 | ||||
-rw-r--r-- | preact/demo/people/profile.tsx | 59 | ||||
-rw-r--r-- | preact/demo/people/router.tsx | 153 | ||||
-rw-r--r-- | preact/demo/people/store.ts | 83 | ||||
-rw-r--r-- | preact/demo/people/styles/animations.scss | 34 | ||||
-rw-r--r-- | preact/demo/people/styles/app.scss | 100 | ||||
-rw-r--r-- | preact/demo/people/styles/avatar.scss | 16 | ||||
-rw-r--r-- | preact/demo/people/styles/button.scss | 115 | ||||
-rw-r--r-- | preact/demo/people/styles/index.scss | 168 | ||||
-rw-r--r-- | preact/demo/people/styles/profile.scss | 26 |
11 files changed, 816 insertions, 0 deletions
diff --git a/preact/demo/people/Readme.md b/preact/demo/people/Readme.md new file mode 100644 index 0000000..c18a1cb --- /dev/null +++ b/preact/demo/people/Readme.md @@ -0,0 +1,3 @@ +# People demo page + +This section of our demo was originally made by [phaux](https://github.com/phaux) in the [web-app-boilerplate](https://github.com/phaux/web-app-boilerplate) repo. It has been slightly modified from it's original to better work inside of our demo app diff --git a/preact/demo/people/index.tsx b/preact/demo/people/index.tsx new file mode 100644 index 0000000..0e728ab --- /dev/null +++ b/preact/demo/people/index.tsx @@ -0,0 +1,59 @@ +import { observer } from 'mobx-react'; +import { Component, h } from 'preact'; +import { Profile } from './profile'; +import { Link, Route, Router } from './router'; +import { store } from './store'; + +import './styles/index.scss'; + +@observer +export default class App extends Component { + componentDidMount() { + store.loadUsers().catch(console.error); + } + + render() { + return ( + <Router> + <div id="people-app"> + <nav> + <div style={{ margin: 16, textAlign: 'center' }}> + Sort by{' '} + <select + value={store.usersOrder} + onChange={(ev: any) => { + store.setUsersOrder(ev.target.value); + }} + > + <option value="name">Name</option> + <option value="id">ID</option> + </select> + </div> + <ul> + {store.getSortedUsers().map((user, i) => ( + <li + key={user.id} + style={{ + animationDelay: `${i * 20}ms`, + top: `calc(var(--menu-item-height) * ${i})`, + transitionDelay: `${i * 20}ms` + }} + > + <Link href={`people/${user.id}`} active> + <img class="avatar" src={user.picture.large} /> + {user.name.first} {user.name.last} + </Link> + </li> + ))} + </ul> + </nav> + <section id="people-main"> + <Route match="people"> + <Route match="*" component={Profile} /> + </Route> + </section> + </div> + </Router> + ); + } +} diff --git a/preact/demo/people/profile.tsx b/preact/demo/people/profile.tsx new file mode 100644 index 0000000..e1f44ea --- /dev/null +++ b/preact/demo/people/profile.tsx @@ -0,0 +1,59 @@ +import { computed, observable } from 'mobx'; +import { observer } from 'mobx-react'; +import { Component, h } from 'preact'; +import { RouteChildProps } from './router'; +import { store } from './store'; + +export type ProfileProps = RouteChildProps; +@observer +export class Profile extends Component<ProfileProps> { + @observable id = ''; + @observable busy = false; + + componentDidMount() { + this.id = this.props.route; + } + + componentWillReceiveProps(props: ProfileProps) { + this.id = props.route; + } + + render() { + const user = this.user; + if (user == null) return null; + return ( + <div class="profile"> + <img class="avatar" src={user.picture.large} /> + <h2> + {user.name.first} {user.name.last} + </h2> + <div class="details"> + <p> + {user.gender === 'female' ? '👩' : '👨'} {user.id} + </p> + <p>🖂 {user.email}</p> + </div> + <p> + <button + class={this.busy ? 'secondary busy' : 'secondary'} + disabled={this.busy} + onClick={this.remove} + > + Remove contact + </button> + </p> + </div> + ); + } + + @computed get user() { + return store.users.find(u => u.id === this.id); + } + + remove = async () => { + this.busy = true; + await new Promise<void>(cb => setTimeout(cb, 1500)); + store.deleteUser(this.id); + this.busy = false; + }; +} diff --git a/preact/demo/people/router.tsx b/preact/demo/people/router.tsx new file mode 100644 index 0000000..56fc420 --- /dev/null +++ b/preact/demo/people/router.tsx @@ -0,0 +1,153 @@ +import { + ComponentChild, + ComponentFactory, + createContext, + FunctionalComponent, + h, + JSX +} from 'preact'; +import { + useCallback, + useContext, + useEffect, + useMemo, + useState +} from 'preact/hooks'; + +export type RouterData = { + match: string[]; + path: string[]; + navigate(path: string): void; +}; + +const RouterContext = createContext<RouterData>({ + match: [], + path: [], + navigate() {} +}); +export const useRouter = () => useContext(RouterContext); + +const useLocation = (cb: () => void) => { + useEffect(() => { + window.addEventListener('popstate', cb); + return () => { + window.removeEventListener('popstate', cb); + }; + }, [cb]); +}; + +export const Router: FunctionalComponent = props => { + const [path, setPath] = useState(location.pathname); + + const update = useCallback(() => { + setPath(location.pathname); + }, [setPath]); + + useLocation(update); + + const navigate = useCallback( + (path: string) => { + history.pushState(null, '', path); + update(); + }, + [update] + ); + + const router = useMemo<RouterData>( + () => ({ + match: [], + navigate, + path: path.split('/').filter(Boolean) + }), + [navigate, path] + ); + + return <RouterContext.Provider children={props.children} value={router} />; +}; + +export type RouteChildProps = { route: string }; +export type RouteProps = { + component?: ComponentFactory<RouteChildProps>; + match: string; + render?(route: string): ComponentChild; +}; +export const Route: FunctionalComponent<RouteProps> = props => { + const router = useRouter(); + const [dir, ...subpath] = router.path; + + if (dir == null) return null; + if (props.match !== '*' && dir !== props.match) return null; + + const children = useMemo(() => { + if (props.component) return <props.component key={dir} route={dir} />; + if (props.render) return props.render(dir); + return props.children; + }, [props.component, props.render, props.children, dir]); + + const innerRouter = useMemo<RouterData>( + () => ({ + ...router, + match: [...router.match, dir], + path: subpath + }), + [router.match, dir, subpath.join('/')] + ); + + return <RouterContext.Provider children={children} value={innerRouter} />; +}; + +export type LinkProps = JSX.HTMLAttributes & { + active?: boolean | string; +}; +export const Link: FunctionalComponent<LinkProps> = props => { + const router = useRouter(); + + const classProps = [props.class, props.className]; + const originalClasses = useMemo(() => { + const classes = []; + for (const prop of classProps) if (prop) classes.push(...prop.split(/\s+/)); + return classes; + }, classProps); + + const activeClass = useMemo(() => { + if (!props.active || props.href == null) return undefined; + const href = props.href.split('/').filter(Boolean); + const path = + props.href[0] === '/' ? [...router.match, ...router.path] : router.path; + const isMatch = href.every((dir, i) => dir === path[i]); + if (isMatch) return props.active === true ? 'active' : props.active; + }, [originalClasses, props.active, props.href, router.match, router.path]); + + const classes = + activeClass == null ? originalClasses : [...originalClasses, activeClass]; + + const getHref = useCallback(() => { + if (props.href == null || props.href[0] === '/') return props.href; + const path = props.href.split('/').filter(Boolean); + return '/' + [...router.match, ...path].join('/'); + }, [router.match, props.href]); + + const handleClick = useCallback( + (ev: MouseEvent) => { + const href = getHref(); + if (props.onClick != null) props.onClick(ev); + if (ev.defaultPrevented) return; + if (href == null) return; + if (ev.button !== 0) return; + if (props.target != null && props.target !== '_self') return; + if (ev.metaKey || ev.altKey || ev.ctrlKey || ev.shiftKey) return; + ev.preventDefault(); + router.navigate(href); + }, + [getHref, router.navigate, props.onClick, props.target] + ); + + return ( + <a + {...props} + class={classes.join(' ')} + href={getHref()} + onClick={handleClick} + /> + ); +}; diff --git a/preact/demo/people/store.ts b/preact/demo/people/store.ts new file mode 100644 index 0000000..1967afd --- /dev/null +++ b/preact/demo/people/store.ts @@ -0,0 +1,83 @@ +import { flow, Instance, types } from 'mobx-state-tree'; + +const cmp = <T, U>(fn: (x: T) => U) => (a: T, b: T): number => + fn(a) > fn(b) ? 1 : -1; + +const User = types.model({ + email: types.string, + gender: types.enumeration(['male', 'female']), + id: types.identifier, + name: types.model({ + first: types.string, + last: types.string + }), + picture: types.model({ + large: types.string + }) +}); + +const Store = types + .model({ + users: types.array(User), + usersOrder: types.enumeration(['name', 'id']) + }) + .views(self => ({ + getSortedUsers() { + if (self.usersOrder === 'name') + return self.users.slice().sort(cmp(x => x.name.first)); + if (self.usersOrder === 'id') + return self.users.slice().sort(cmp(x => x.id)); + throw Error(`Unknown ordering ${self.usersOrder}`); + } + })) + .actions(self => ({ + addUser: flow<unknown, []>(function*() { + const data = yield fetch('https://randomuser.me/api?results=1') + .then(res => res.json()) + .then(data => + data.results.map((user: any) => ({ + ...user, + id: user.login.username + })) + ); + self.users.push(...data); + }), + loadUsers: flow<unknown, []>(function*() { + const data = yield fetch( + `https://randomuser.me/api?seed=${12321}&results=12` + ) + .then(res => res.json()) + .then(data => + data.results.map((user: any) => ({ + ...user, + id: user.login.username + })) + ); + self.users.replace(data); + }), + deleteUser(id: string) { + const user = self.users.find(u => u.id === id); + if (user != null) self.users.remove(user); + }, + setUsersOrder(order: 'name' | 'id') { + self.usersOrder = order; + } + })); + +export type StoreType = Instance<typeof Store>; +export const store = Store.create({ + usersOrder: 'name', + users: [] +}); + +// const { Provider, Consumer } = createContext<StoreType>(undefined as any) + +// export const StoreProvider: FunctionalComponent = props => { +// const store = Store.create({}) +// return <Provider value={store} children={props.children} /> +// } + +// export type StoreProps = {store: StoreType} +// export function injectStore<T>(Child: AnyComponent<T & StoreProps>): FunctionalComponent<T> { +// return props => <Consumer render={store => <Child {...props} store={store}/>}/> +// } diff --git a/preact/demo/people/styles/animations.scss b/preact/demo/people/styles/animations.scss new file mode 100644 index 0000000..c7ce8cb --- /dev/null +++ b/preact/demo/people/styles/animations.scss @@ -0,0 +1,34 @@ +@keyframes popup { + from { + box-shadow: 0 0 0 black; + opacity: 0; + transform: scale(0.9); + } + to { + box-shadow: 0 30px 70px rgba(0, 0, 0, 0.5); + opacity: 1; + transform: none; + } +} + +@keyframes zoom { + from { + opacity: 0; + transform: scale(0.8); + } + to { + opacity: 1; + transform: none; + } +} + +@keyframes appear-from-left { + from { + opacity: 0; + transform: translateX(-25px); + } + to { + opacity: 1; + transform: none; + } +} diff --git a/preact/demo/people/styles/app.scss b/preact/demo/people/styles/app.scss new file mode 100644 index 0000000..4473f9e --- /dev/null +++ b/preact/demo/people/styles/app.scss @@ -0,0 +1,100 @@ +#people-app { + position: relative; + overflow: hidden; + min-height: 100vh; + animation: popup 300ms cubic-bezier(0.3, 0.7, 0.3, 1) forwards; + background: var(--app-background); + --menu-width: 260px; + --menu-item-height: 50px; + + @media (min-width: 1280px) { + max-width: 1280px; + min-height: calc(100vh - 64px); + margin: 32px auto; + border-radius: 10px; + } + + > nav { + position: absolute; + display: flow-root; + width: var(--menu-width); + height: 100%; + background-color: var(--app-background-secondary); + overflow-x: hidden; + overflow-y: auto; + } + + > nav h4 { + padding-left: 16px; + font-weight: normal; + text-transform: uppercase; + } + + > nav ul { + position: relative; + } + + > nav li { + position: absolute; + width: 100%; + animation: zoom 200ms forwards; + opacity: 0; + transition: top 200ms; + } + + > nav li > a { + position: relative; + display: flex; + overflow: hidden; + flex-flow: row; + align-items: center; + margin-left: 16px; + border-right: 2px solid transparent; + border-bottom-left-radius: 48px; + border-top-left-radius: 48px; + text-transform: capitalize; + transition: border 500ms; + } + + > nav li > a:hover { + background-color: var(--app-highlight); + } + + > nav li > a::after { + position: absolute; + top: 0; + right: -2px; + bottom: 0; + left: 0; + background-image: radial-gradient( + circle, + var(--app-ripple) 1%, + transparent 1% + ); + background-position: center; + background-repeat: no-repeat; + background-size: 10000%; + content: ''; + opacity: 0; + transition: opacity 700ms, background 300ms; + } + + > nav li > a:active::after { + background-size: 100%; + opacity: 0.5; + transition: none; + } + + > nav li > a.active { + border-color: var(--app-primary); + background-color: var(--app-highlight); + } + + > nav li > a > * { + margin: 8px; + } + + #people-main { + padding-left: var(--menu-width); + } +} diff --git a/preact/demo/people/styles/avatar.scss b/preact/demo/people/styles/avatar.scss new file mode 100644 index 0000000..437c6c4 --- /dev/null +++ b/preact/demo/people/styles/avatar.scss @@ -0,0 +1,16 @@ +#people-app { + .avatar { + display: inline-block; + overflow: hidden; + width: var(--avatar-size, 32px); + height: var(--avatar-size, 32px); + background-color: var(--avatar-color, var(--app-primary)); + border-radius: 50%; + font-size: calc(var(--avatar-size, 32px) * 0.5); + line-height: var(--avatar-size, 32px); + object-fit: cover; + text-align: center; + text-transform: uppercase; + white-space: nowrap; + } +} diff --git a/preact/demo/people/styles/button.scss b/preact/demo/people/styles/button.scss new file mode 100644 index 0000000..ce43138 --- /dev/null +++ b/preact/demo/people/styles/button.scss @@ -0,0 +1,115 @@ +#people-app { + button { + position: relative; + overflow: hidden; + min-width: 36px; + height: 36px; + padding: 0 16px; + border: none; + background-color: transparent; + border-radius: 4px; + color: var(--app-text); + font-family: 'Montserrat', sans-serif; + font-size: 14px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + transition: background 300ms, color 200ms; + white-space: nowrap; + } + + button::before { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + background-color: var(--app-ripple); + content: ''; + opacity: 0; + transition: opacity 200ms; + } + + button:hover:not(:disabled)::before { + opacity: 0.3; + transition: opacity 100ms; + } + + button:active:not(:disabled)::before { + opacity: 0.7; + transition: none; + } + + button::after { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + background-image: radial-gradient( + circle, + var(--app-ripple) 1%, + transparent 1% + ); + background-position: center; + background-repeat: no-repeat; + background-size: 20000%; + content: ''; + opacity: 0; + transition: opacity 700ms, background 400ms; + } + + button:active:not(:disabled)::after { + background-size: 100%; + opacity: 1; + transition: none; + } + + button.primary { + background-color: var(--app-primary); + box-shadow: 0 2px 6px var(--app-shadow); + } + + button.secondary { + background-color: var(--app-secondary); + box-shadow: 0 2px 6px var(--app-shadow); + } + + button:disabled { + color: var(--app-text-secondary); + } + + button.busy { + animation: stripes 500ms linear infinite; + background-image: repeating-linear-gradient( + 45deg, + var(--app-shadow) 0%, + var(--app-shadow) 25%, + transparent 25%, + transparent 50%, + var(--app-shadow) 50%, + var(--app-shadow) 75%, + transparent 75%, + transparent 100% + ); + color: var(--app-text); + /* letter-spacing: -.7em; */ + } + + button:disabled:not(.primary):not(.secondary).busy, + button:disabled.primary:not(.busy), + button:disabled.secondary:not(.busy) { + background-color: var(--app-background-disabled); + } + + @keyframes stripes { + from { + background-position-x: 0; + background-size: 16px 16px; + } + to { + background-position-x: 16px; + background-size: 16px 16px; + } + } +} diff --git a/preact/demo/people/styles/index.scss b/preact/demo/people/styles/index.scss new file mode 100644 index 0000000..80b342e --- /dev/null +++ b/preact/demo/people/styles/index.scss @@ -0,0 +1,168 @@ +@import 'app.scss'; +@import 'animations.scss'; +@import 'avatar.scss'; +@import 'profile.scss'; +@import 'button.scss'; + +// :root { +#people-app { + --app-background: #2f2b43; + --app-background-secondary: #353249; + --app-background-disabled: #555366; + --app-highlight: rgba(255, 255, 255, 0.1); + --app-ripple: rgba(255, 255, 255, 0.5); + --app-shadow: rgba(0, 0, 0, 0.15); + --app-text: #fff; + --app-text-secondary: #807e97; + --app-primary: #ff0087; + --app-secondary: #4d7cfe; + --app-tertiary: #00ec97; + --app-danger: #f3c835; + --spinner-size: 200px; +} + +* { + box-sizing: border-box; +} + +// body { +#people-app { + // display: flow-root; + // overflow: auto; + // min-height: 100vh; + // margin: 0; + // animation: background-light 5s ease-out forwards; + // /* very fancy background */ + // background: radial-gradient( + // circle 15px at 150px 90vh, + // rgba(255, 255, 255, 0.35), + // rgba(255, 255, 255, 0.35) 90%, + // transparent + // ), + // radial-gradient( + // circle 9px at 60px 50vh, + // rgba(255, 255, 255, 0.55), + // rgba(255, 255, 255, 0.55) 90%, + // transparent + // ), + // radial-gradient( + // circle 19px at 40vw 70px, + // rgba(255, 255, 255, 0.3), + // rgba(255, 255, 255, 0.3) 90%, + // transparent + // ), + // radial-gradient( + // circle 12px at 80vw 80px, + // rgba(255, 255, 255, 0.4), + // rgba(255, 255, 255, 0.4) 90%, + // transparent + // ), + // radial-gradient( + // circle 7px at 55vw calc(100vh - 95px), + // rgba(255, 255, 255, 0.6), + // rgba(255, 255, 255, 0.6) 90%, + // transparent + // ), + // radial-gradient( + // circle 14px at 25vw calc(100vh - 35px), + // rgba(255, 255, 255, 0.4), + // rgba(255, 255, 255, 0.4) 90%, + // transparent + // ), + // radial-gradient( + // circle 11px at calc(100vw - 95px) 55vh, + // rgba(255, 255, 255, 0.45), + // rgba(255, 255, 255, 0.45) 90%, + // transparent + // ), + // radial-gradient( + // circle 13px at calc(100vw - 35px) 85vh, + // rgba(255, 255, 255, 0.4), + // rgba(255, 255, 255, 0.4) 90%, + // transparent + // ), + // radial-gradient( + // circle 50vw at 0 -25%, + // rgba(255, 255, 255, 0.07), + // rgba(255, 255, 255, 0.07) 100%, + // transparent + // ), + // radial-gradient( + // circle 80vw at top left, + // rgba(255, 255, 255, 0.07), + // rgba(255, 255, 255, 0.07) 100%, + // transparent + // ), + // radial-gradient(circle at bottom right, #ef2fb8, transparent), + // radial-gradient(circle at top right, #c45af3, transparent), + // linear-gradient(#ee66ca, #ff47a6); + color: var(--app-text); + font-family: 'Montserrat', sans-serif; +} + +#people-app { + .spinner { + position: absolute; + top: 200px; + left: calc(50% - var(--spinner-size) / 2); + width: var(--spinner-size); + height: var(--spinner-size); + animation: zoom 250ms 500ms forwards ease-out; + opacity: 0; + transition: opacity 200ms, transform 200ms ease-in; + } + + .spinner.exit { + opacity: 0; + transform: scale(0.5); + } + + .spinner::before, + .spinner::after { + position: absolute; + top: 0; + left: 0; + width: calc(var(--spinner-size) / 3); + height: calc(var(--spinner-size) / 3); + animation: spinner 2s infinite ease-in-out; + background-color: rgba(255, 255, 255, 0.6); + content: ''; + } + + .spinner::after { + animation-delay: -1s; + } + + @keyframes spinner { + 25% { + transform: translateX(calc(var(--spinner-size) / 3 * 2 - 1px)) + rotate(-90deg) scale(0.5); + } + 50% { + transform: translateX(calc(var(--spinner-size) / 3 * 2 - 1px)) + translateY(calc(var(--spinner-size) / 3 * 2 - 1px)) rotate(-179deg); + } + 50.1% { + transform: translateX(calc(var(--spinner-size) / 3 * 2 - 1px)) + translateY(calc(var(--spinner-size) / 3 * 2 - 1px)) rotate(-180deg); + } + 75% { + transform: translateX(0) translateY(calc(var(--spinner-size) / 3 * 2 - 1px)) + rotate(-270deg) scale(0.5); + } + 100% { + transform: rotate(-360deg); + } + } + + ul, + ol { + padding-left: 0; + list-style: none; + } + + a { + color: inherit; + text-decoration: none; + } +} diff --git a/preact/demo/people/styles/profile.scss b/preact/demo/people/styles/profile.scss new file mode 100644 index 0000000..f74b760 --- /dev/null +++ b/preact/demo/people/styles/profile.scss @@ -0,0 +1,26 @@ +#people-app { + .profile { + display: flex; + flex-flow: column; + align-items: center; + margin: 32px 0; + animation: appear-from-left 0.5s forwards; + --avatar-size: 80px; + } + + .profile h2 { + text-transform: capitalize; + } + + .profile .details { + display: flex; + flex-flow: column; + align-items: stretch; + margin: 16px auto; + } + + .profile .details p { + margin-top: 8px; + margin-bottom: 8px; + } +} |