diff --git a/README.md b/README.md index f0c4db868..03129027c 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,12 @@ npm install npm start ``` +Or run the examples directory with livereloading + +```shell +npm run budo +``` + ## Usage ### Install diff --git a/browserslist b/browserslist new file mode 100644 index 000000000..5e629b131 --- /dev/null +++ b/browserslist @@ -0,0 +1,9 @@ +last 10 Chrome versions +last 10 Firefox versions +last 10 Edge versions +last 10 iOS versions +last 10 Android versions +last 10 Opera versions +last 10 Safari versions +last 10 ExplorerMobile versions +Explorer >= 9 diff --git a/examples/index.tsx b/examples/index.tsx new file mode 100644 index 000000000..9bed8be94 --- /dev/null +++ b/examples/index.tsx @@ -0,0 +1,111 @@ +import * as React from 'react'; +import * as ReactDOM from 'react-dom'; +import { + AppRoot, + Button, + Column, + Container, + ContentBox, + ContentBoxFooter, + ContentBoxHeader, + DabIpsum, + Footer, + NavBar, + Row, + Section, + SpacedGroup, +} from '../src/ts'; + +const app = document.createElement('app'); + +document.body.appendChild(app); + +ReactDOM.render( + ( + + + +

+ NavBar +

+ + + + +
+
+ + +

+ Example +

+ + + + + +

+ Example +

+
+ +
+ + + Column 1 + + + Column 1 + + + Column 1 + + + Column 1 + + +
+ +
+ + + Column 1 + + + Column 1 + + + Column 1 + + + Column 1 + + +
+ + + + + + + +
+
+ + +
+ ), + app +); diff --git a/examples/tsconfig.json b/examples/tsconfig.json new file mode 100644 index 000000000..3c43903cf --- /dev/null +++ b/examples/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../tsconfig.json" +} diff --git a/package.json b/package.json index d74f2ba4a..4218bfa4f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@dabapps/roe", - "version": "0.8.36", + "version": "0.9.0", "description": "A Collection of React Components for Project Development", "main": "dist/js/index.js", "types": "dist/js/index.d.ts", @@ -13,6 +13,7 @@ "lint": "npm run lint-js && npm run lint-less", "tests": "jest", "test": "npm run lint && npm run tests -- --coverage --runInBand", + "budo": "budo src/less/index.less examples/index.tsx --live -- -t node-lessify -p [tsify -p examples/tsconfig.json]", "prepublish": "./scripts/dist" }, "repository": { @@ -49,7 +50,6 @@ "less": "2.7.2", "normalize.css": "6.0.0", "postcss": "5.2.17", - "postcss-cli": "3.2.0", "random-seed": "0.3.0", "react": "15.5.4", "react-dom": "15.5.4", @@ -64,7 +64,7 @@ "@types/node": "7.0.13", "@types/react-test-renderer": "15.4.5", "brfs": "1.4.3", - "concurrently": "3.4.0", + "budo": "11.1.0", "css-loader": "0.28.7", "envify": "4.0.0", "enzyme": "3.2.0", @@ -77,6 +77,7 @@ "less-loader": "4.0.5", "lesshint": "3.3.1", "livereload": "0.6.2", + "node-lessify": "0.1.5", "postcss-loader": "2.0.7", "react-docgen-typescript": "1.2.2", "react-scripts-ts": "2.8.0", @@ -86,8 +87,6 @@ "ts-jest": "20.0.14", "tslint": "5.8.0", "tslint-config-dabapps": "github:dabapps/tslint-config-dabapps#v0.3.1", - "watch-less-do-more": "0.2.0", - "watchify": "3.9.0", "webpack": "3.7.1" }, "peerDependencies": { diff --git a/src/less/app.less b/src/less/app.less new file mode 100644 index 000000000..d37684357 --- /dev/null +++ b/src/less/app.less @@ -0,0 +1,4 @@ +.app-root { + position: relative; + min-height: 100%; +} diff --git a/src/less/footer.less b/src/less/footer.less new file mode 100644 index 000000000..faf7a8b98 --- /dev/null +++ b/src/less/footer.less @@ -0,0 +1,12 @@ +.footer { + height: @footer-height; + background-color: @footer-background; + border-top: @footer-border; + + &.sticky { + position: absolute; + left: 0; + bottom: 0; + width: 100%; + } +} diff --git a/src/less/index.less b/src/less/index.less index 3d5b1e112..7f4d20673 100644 --- a/src/less/index.less +++ b/src/less/index.less @@ -2,8 +2,10 @@ @import 'variables.less'; @import 'overrides.less'; +@import 'app.less'; @import 'grid.less'; @import 'nav-bar.less'; +@import 'footer.less'; @import 'buttons.less'; @import 'inputs.less'; @import 'layout.less'; diff --git a/src/less/layout.less b/src/less/layout.less index 4a94a3dfe..ba33b4273 100644 --- a/src/less/layout.less +++ b/src/less/layout.less @@ -12,7 +12,7 @@ margin-left: -@gutter-width / 2; margin-right: -@gutter-width / 2; - &:last-child { + &:last-of-type { border-bottom: @border-none; } } diff --git a/src/less/nav-bar.less b/src/less/nav-bar.less index c301e26c9..4c9f695a6 100644 --- a/src/less/nav-bar.less +++ b/src/less/nav-bar.less @@ -1,7 +1,3 @@ -body.with-fixed-nav-bar { - padding-top: @nav-bar-height; -} - .nav-bar { .clearfix(); padding: @padding-base 0; diff --git a/src/less/overrides.less b/src/less/overrides.less index a8aaf1ff9..9b8767a9b 100644 --- a/src/less/overrides.less +++ b/src/less/overrides.less @@ -12,6 +12,7 @@ body { font-size: @font-size-base; line-height: @line-height-base; letter-spacing: @letter-spacing-base; + height: 100%; } h1, diff --git a/src/less/variables.less b/src/less/variables.less index 8345b4316..f73809bec 100644 --- a/src/less/variables.less +++ b/src/less/variables.less @@ -70,7 +70,11 @@ @nav-bar-link-text-decoration-hover: @link-text-decoration-hover; @nav-bar-background: @body-background; @nav-bar-border: @border-base; -@nav-bar-height: @input-height + @padding-base * 2; +@nav-bar-height: auto; + +@footer-background: @body-background; +@footer-border: @border-base; +@footer-height: auto; @button-text-color-dark: @grey-dark; @button-text-color-light: @grey-lightest; diff --git a/src/ts/components/app/root.examples.md b/src/ts/components/app/root.examples.md new file mode 100644 index 000000000..df47fb507 --- /dev/null +++ b/src/ts/components/app/root.examples.md @@ -0,0 +1,23 @@ +#### Example + +```js static + + + + NavBar + + + + +

+ Content +

+
+ + +
+``` diff --git a/src/ts/components/app/root.tsx b/src/ts/components/app/root.tsx new file mode 100644 index 000000000..241128795 --- /dev/null +++ b/src/ts/components/app/root.tsx @@ -0,0 +1,53 @@ +import * as classNames from 'classnames'; +import * as React from 'react'; +import { HTMLProps, PureComponent } from 'react'; +import store, { StoreState } from '../../store'; +import { ComponentProps } from '../../types'; + +export type AppRootProps = HTMLProps & ComponentProps & StoreState; + +/** + * This is the most important part of your app. + * This component interacts with other Roe components to adjust styles at the root level. + * Your app must have an AppRoot if you wish to used a fixed / shy NavBar or sticky Footer. + */ +export class AppRootUnconnected extends PureComponent { + public render () { + const { + component: Component = 'div', + children, + className, + hasStickyFooter, + hasFixedNavBar, + navBarHeight, + footerHeight, + ...remainingProps, + } = this.props; + + const myClassNames = [ + 'app-root', + hasStickyFooter && 'has-sticky-footer' || null, + hasFixedNavBar && 'has-fixed-nav-bar' || null, + className, + ]; + + const style = { + paddingTop: hasFixedNavBar && navBarHeight, + paddingBottom: hasStickyFooter && footerHeight, + }; + + return ( + + {children} + + ); + } +} + +export const AppRoot = store.connect(AppRootUnconnected); + +export default AppRoot; diff --git a/src/ts/components/collapse.tsx b/src/ts/components/collapse.tsx index faa8fa4b8..30ab5d9b0 100644 --- a/src/ts/components/collapse.tsx +++ b/src/ts/components/collapse.tsx @@ -104,7 +104,7 @@ export class Collapse extends PureComponent { }); } - public componentWillMount () { + public componentWillUnmount () { window.clearTimeout(this.timeout); } diff --git a/src/ts/components/navigation/footer.examples.md b/src/ts/components/navigation/footer.examples.md new file mode 100644 index 000000000..1285ea950 --- /dev/null +++ b/src/ts/components/navigation/footer.examples.md @@ -0,0 +1,17 @@ +#### Example + +```js +
+

+ Footer +

+
+``` + +#### Less variables + +```less +@footer-background: @body-background; +@footer-border: @border-base; +@footer-height: auto; +``` diff --git a/src/ts/components/navigation/footer.tsx b/src/ts/components/navigation/footer.tsx new file mode 100644 index 000000000..034cb83dd --- /dev/null +++ b/src/ts/components/navigation/footer.tsx @@ -0,0 +1,73 @@ +import * as classNames from 'classnames'; +import * as React from 'react'; +import { HTMLProps, PureComponent } from 'react'; +import * as ReactDOM from 'react-dom'; +import store from '../../store'; +import { ComponentProps } from '../../types'; + +export interface FooterProps extends ComponentProps, HTMLProps { + /** + * Fix the footer to the bottom of the window when there is not enough content to push it down. + */ + sticky?: boolean; +} + +export class Footer extends PureComponent { + public componentDidMount () { + this.notifyAppRoot(this.props); + this.toggleResizeListeners(this.props); + } + + public componentWillUpdate (nextProps: FooterProps) { + if (Boolean(this.props.sticky) !== Boolean(nextProps.sticky)) { + this.notifyAppRoot(nextProps); + this.toggleResizeListeners(nextProps); + } + } + + public componentWillUnmount () { + window.removeEventListener('resize', this.updateAppRoot); + this.notifyAppRoot({sticky: false}); + } + + public render () { + const { + sticky, + component: Component = 'div', + children, + ...remainingProps, + } = this.props; + + return ( + + {children} + + ); + } + + private notifyAppRoot (props: FooterProps) { + const { sticky } = props; + const element = ReactDOM.findDOMNode(this); + + store.setState({ + hasStickyFooter: Boolean(sticky), + footerHeight: element ? element.getBoundingClientRect().height : undefined, + }); + } + + private updateAppRoot = () => { + this.notifyAppRoot(this.props); + } + + private toggleResizeListeners(props: FooterProps) { + const { sticky } = props; + + if (sticky) { + window.addEventListener('resize', this.updateAppRoot); + } else { + window.removeEventListener('resize', this.updateAppRoot); + } + } +} + +export default Footer; diff --git a/src/ts/components/navigation/nav-bar.tsx b/src/ts/components/navigation/nav-bar.tsx index 10424f0ea..5292f00f5 100644 --- a/src/ts/components/navigation/nav-bar.tsx +++ b/src/ts/components/navigation/nav-bar.tsx @@ -2,10 +2,9 @@ import * as classNames from 'classnames'; import * as React from 'react'; import { HTMLProps, PureComponent } from 'react'; import * as ReactDOM from 'react-dom'; +import store from '../../store'; import { ComponentProps } from '../../types'; -import { addClassName, getScrollOffset, removeClassName } from '../../utils'; - -const WITH_FIXED_NAV_BAR = 'with-fixed-nav-bar'; +import { getScrollOffset } from '../../utils'; export interface NavBarProps extends ComponentProps, HTMLProps { /** @@ -39,24 +38,28 @@ export class NavBar extends PureComponent { }; } - public componentWillMount () { - this.updateBodyClass(this.props); + public componentDidMount () { + this.notifyAppRoot(this.props); this.toggleShyListeners(this.props); + this.toggleResizeListeners(this.props); } public componentWillUpdate (nextProps: NavBarProps) { - if (this.props.shy !== nextProps.shy) { + if (Boolean(this.props.shy) !== Boolean(nextProps.shy)) { this.toggleShyListeners(nextProps); } - if (this.props.fixed !== nextProps.fixed || this.props.shy !== nextProps.shy) { - this.updateBodyClass(nextProps); + if (Boolean(this.props.fixed) !== Boolean(nextProps.fixed) || Boolean(this.props.shy) !== Boolean(nextProps.shy)) { + this.notifyAppRoot(nextProps); + this.toggleResizeListeners(nextProps); } } public componentWillUnmount () { window.removeEventListener('scroll', this.hideOrShowNavBar); window.removeEventListener('resize', this.hideOrShowNavBar); + window.removeEventListener('resize', this.updateAppRoot); + this.notifyAppRoot({fixed: false}); } public render () { @@ -90,13 +93,27 @@ export class NavBar extends PureComponent { ); } - private updateBodyClass (props: NavBarProps) { + private notifyAppRoot (props: NavBarProps) { + const { fixed, shy } = props; + const element = ReactDOM.findDOMNode(this); + + store.setState({ + hasFixedNavBar: Boolean(fixed || shy), + navBarHeight: element ? element.getBoundingClientRect().height : undefined, + }); + } + + private updateAppRoot = () => { + this.notifyAppRoot(this.props); + } + + private toggleResizeListeners (props: NavBarProps) { const { fixed, shy } = props; if (fixed || shy) { - addClassName(document.body, WITH_FIXED_NAV_BAR); + window.addEventListener('resize', this.updateAppRoot); } else { - removeClassName(document.body, WITH_FIXED_NAV_BAR) + window.removeEventListener('resize', this.updateAppRoot); } } diff --git a/src/ts/constants.ts b/src/ts/constants.ts index 242d2452c..fc128ca3e 100644 --- a/src/ts/constants.ts +++ b/src/ts/constants.ts @@ -1,6 +1,5 @@ export const NBSP = '\u00a0'; -export const MATCHES_WHITESPACE = /\s+/g; export const MATCHES_INITIAL_INDENTATION = /^([^\S\n]*)\S/; export const MATCHES_BLANK_FIRST_LINE = /^\s*\n/; export const MATCHES_BLANK_LAST_LINE = /\n\s*$/; diff --git a/src/ts/index.ts b/src/ts/index.ts index cb237caa6..4c79d1366 100644 --- a/src/ts/index.ts +++ b/src/ts/index.ts @@ -1,4 +1,5 @@ export { default as Alert } from './components/alert'; +export { default as AppRoot } from './components/app/root'; export { default as Anchor } from './components/anchor'; export { default as Button } from './components/forms/button'; export { default as CodeBlock } from './components/code-block'; @@ -9,6 +10,7 @@ export { default as ContentBox } from './components/content/content-box'; export { default as ContentBoxHeader } from './components/content/content-box-header'; export { default as ContentBoxFooter } from './components/content/content-box-footer'; export { default as DabIpsum } from './components/prototyping/dab-ipsum'; +export { default as Footer } from './components/navigation/footer'; export { default as FormGroup } from './components/forms/form-group'; export { default as InputGroup } from './components/forms/input-group'; export { default as InputGroupAddon } from './components/forms/input-group-addon'; diff --git a/src/ts/store.tsx b/src/ts/store.tsx new file mode 100644 index 000000000..f5dfb72a9 --- /dev/null +++ b/src/ts/store.tsx @@ -0,0 +1,99 @@ +import * as React from 'react'; + +export type ComponentType

= React.ComponentClass

| React.StatelessComponent

; + +export type StoreState = Partial<{ + hasFixedNavBar: boolean; + hasStickyFooter: boolean; + navBarHeight: number; + footerHeight: number; +}>; + +export type StoreListener = (state: StoreState) => any; + +export const createConnectedComponent = + (store: Store, component: ComponentType): ComponentType => { + const Component = component; + + return class ConnectedComponent extends React.PureComponent { + private unsubscribe: () => void; + public constructor (props: OwnProps) { + super(props); + + this.state = store.getState(); + } + + public componentWillMount () { + this.unsubscribe = store.subscribe((state) => { + this.setState(state); + }); + } + + public componentWillUnmount () { + this.unsubscribe(); + } + + public render () { + const { + children, + ...remainingProps, + } = this.props as any; + + return ( + + {children} + + ); + } + } +}; + +// tslint:disable-next-line:max-classes-per-file +export class Store { + private state: StoreState = {}; + private listeners: StoreListener[] = []; + + public constructor (initialState: StoreState = {}) { + this.state = initialState; + } + + public setState = (state: StoreState) => { + for (const key in state) { + /* istanbul ignore else */ + if (Object.prototype.hasOwnProperty.call(state, key)) { + this.state[key as keyof StoreState] = state[key as keyof StoreState]; + } + } + + this.listeners.forEach((listener) => { + listener({...this.state}); + }); + } + + public getState = () => { + return {...this.state}; + } + + public connect = + (component: ComponentType): ComponentType => { + return createConnectedComponent(this, component); + } + + public subscribe = (listener: StoreListener) => { + if (this.listeners.indexOf(listener) < 0) { + this.listeners.push(listener); + } + + return this.createUnsubscriber(listener); + } + + private createUnsubscriber = (listener: StoreListener) => () => { + const index = this.listeners.indexOf(listener); + + if (index >= 0) { + this.listeners.splice(index, 1); + } + } +} + +export default new Store(); diff --git a/src/ts/utils.ts b/src/ts/utils.ts index 5ee51167b..918a9e6da 100644 --- a/src/ts/utils.ts +++ b/src/ts/utils.ts @@ -6,7 +6,6 @@ import { MATCHES_INITIAL_INDENTATION, MATCHES_LEADING_AND_TRAILING_HYPHENS, MATCHES_NON_WORD_CHARACTERS, - MATCHES_WHITESPACE, } from './constants'; export const formatCode = (code: string) => { @@ -58,34 +57,6 @@ export const shouldNotBeRendered = (children: any) => { export const isValidColumnNumber = (value?: number) => typeof value === 'number' && value === +value; -export const addClassName = (element: HTMLElement, className: string) => { - const myClassNames = element.className - .trim() - .split(MATCHES_WHITESPACE); - - if (myClassNames.indexOf(className) >= 0) { - return; - } - - element.className = [...myClassNames, className].join(' '); -}; - -export const removeClassName = (element: HTMLElement, className: string) => { - const myClassNames = element.className - .trim() - .split(MATCHES_WHITESPACE); - - const index = myClassNames.indexOf(className); - - if (index < 0) { - return; - } - - myClassNames.splice(index, 1); - - element.className = myClassNames.join(' '); -}; - export const getScrollOffset = () => { const doc = document.documentElement; const left = (window.pageXOffset || doc.scrollLeft) - (doc.clientLeft || 0); diff --git a/styleguide.config.js b/styleguide.config.js index 2e7e5c3ec..a3502c9db 100644 --- a/styleguide.config.js +++ b/styleguide.config.js @@ -5,6 +5,14 @@ var fs = require('fs'); var path = require('path'); var components = [ + { + name: 'App', + components: 'src/ts/components/app/**/*.tsx' + }, + { + name:'Navigation', + components: 'src/ts/components/navigation/**/*.tsx' + }, { name: 'Content', components: 'src/ts/components/content/**/*.tsx' @@ -25,10 +33,6 @@ var components = [ name: 'Modals', components: 'src/ts/components/modals/**/*.tsx' }, - { - name:'Navigation', - components: 'src/ts/components/navigation/**/*.tsx' - }, { name: 'Forms', components: 'src/ts/components/forms/**/*.tsx' @@ -44,10 +48,6 @@ var components = [ ]; var less = [ - { - name: 'Variables', - content: 'src/less/variables.examples.md' - }, { name: 'Atomic float classes', content: 'src/less/float.examples.md' @@ -67,23 +67,13 @@ var less = [ { name: 'Atomic text align classes', content: 'src/less/text-align.examples.md' + }, + { + name: 'Variables', + content: 'src/less/variables.examples.md' } ]; -function sortByName (arr) { - return arr.sort(function (a, b) { - if (a.name > b.name) { - return 1; - } - - if (a.name < b.name) { - return -1; - } - - return 0; - }); -} - function getExampleFilename (componentPath) { return componentPath.replace(/\.tsx?$/, '.examples.md'); } @@ -155,16 +145,16 @@ module.exports = { styleguideComponents: { Logo: path.join(__dirname, 'docs/components/logo'), }, - sections: sortByName([ + sections: [ { name: 'Components', - sections: sortByName(components) + sections: components }, { name: 'Less', - sections: sortByName(less) + sections: less } - ]), + ], styles: { /* Component: { diff --git a/tests/__snapshots__/footer.tsx.snap b/tests/__snapshots__/footer.tsx.snap new file mode 100644 index 000000000..57cc651fb --- /dev/null +++ b/tests/__snapshots__/footer.tsx.snap @@ -0,0 +1,19 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Footer should apply sticky class 1`] = ` +

+`; + +exports[`Footer should match snapshot 1`] = ` +
+`; + +exports[`Footer should take regular element attributes 1`] = ` +
+`; diff --git a/tests/__snapshots__/root.tsx.snap b/tests/__snapshots__/root.tsx.snap new file mode 100644 index 000000000..d9c388820 --- /dev/null +++ b/tests/__snapshots__/root.tsx.snap @@ -0,0 +1,49 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AppRoot should accept regular element attributes 1`] = ` +
+`; + +exports[`AppRoot should apply classes for fixed nav bar and sticky footer 1`] = ` +
+`; + +exports[`AppRoot should apply padding for fixed nav bar and sticky footer 1`] = ` +
+`; + +exports[`AppRoot should match snapshot 1`] = ` +
+`; diff --git a/tests/collapse.tsx b/tests/collapse.tsx index d301ac0f4..9add8a030 100644 --- a/tests/collapse.tsx +++ b/tests/collapse.tsx @@ -67,6 +67,16 @@ describe('Collapse', () => { expect(tree).toMatchSnapshot(); }); + it('should clear its timeout on unmount', () => { + jest.spyOn(window, 'clearTimeout'); + + const instance = enzyme.mount(); + + instance.unmount(); + + expect(window.clearTimeout).toHaveBeenCalledTimes(1); + }); + it('should open from default height', () => { jest.useFakeTimers(); diff --git a/tests/components.ts b/tests/components.ts index 6d2fb4b7c..a75130a80 100644 --- a/tests/components.ts +++ b/tests/components.ts @@ -44,10 +44,10 @@ describe('components', () => { throw new Error(`No default export in component at ${filePath}`); } - const classRegex = new RegExp(`^export class ${defaultExport[1]}`, 'm'); + const classRegex = new RegExp(`^export (class|const) ${defaultExport[1]}`, 'm'); if (!classRegex.test(content)) { - throw new Error(`Default export ${defaultExport[0]} is not exported as a named class at ${filePath}`); + throw new Error(`Default export ${defaultExport[0]} is not exported as a named class or const at ${filePath}`); } }); }); diff --git a/tests/footer.tsx b/tests/footer.tsx new file mode 100644 index 000000000..ca959a86c --- /dev/null +++ b/tests/footer.tsx @@ -0,0 +1,153 @@ +import * as enzyme from 'enzyme'; +import * as React from 'react'; +import * as ReactDOM from 'react-dom'; +import * as renderer from 'react-test-renderer'; +import Footer from '../src/ts/components/navigation/footer'; +import store from '../src/ts/store'; + +jest.mock('react-dom', () => ({ + findDOMNode: () => null, +})); + +jest.mock('../src/ts/store', () => ({ + default: { + setState: jest.fn(), + } +})); + +describe('Footer', () => { + + beforeAll(() => { + jest.spyOn(window, 'addEventListener'); + jest.spyOn(window, 'removeEventListener'); + }); + + beforeEach(() => { + (store.setState as jest.Mock).mockClear(); + (window.addEventListener as jest.Mock).mockImplementation(jest.fn()).mockClear(); + (window.removeEventListener as jest.Mock).mockImplementation(jest.fn()).mockClear(); + }); + + it('should match snapshot', () => { + const tree = renderer.create( +